@wendellresearch-ai/benchwarmers 0.1.1 → 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 -271
- package/NOTICE +28 -28
- package/PRIVACY.md +46 -46
- package/README.md +368 -275
- package/SECURITY.md +83 -83
- package/TERMS.md +22 -22
- package/USAGE.md +170 -155
- package/VERSION +1 -1
- package/bin/benchwarmers.js +16 -72
- package/cli.py +666 -0
- package/examples/config/gateway.json +5 -5
- package/examples/config/model-roles.json +11 -11
- package/gateway.py +755 -712
- package/launcher.py +120 -0
- package/mcp_server.py +266 -0
- 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 -373
- 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 -574
- package/metrics/scheduler.py +275 -275
- package/metrics/sources.py +167 -167
- package/metrics/wrong.py +144 -144
- package/package.json +68 -64
- package/perf/README.md +89 -89
- package/perf/run.py +261 -261
- package/scripts/auto_retire.py +367 -367
- package/scripts/check.py +108 -104
- 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 -0
- package/scripts/test_mcp.py +178 -0
- package/scripts/test_merged.py +214 -193
- package/scripts/test_no_owui.py +291 -291
- package/scripts/test_owui.py +90 -90
- package/scripts/test_serve_edit.py +448 -386
- package/scripts/test_verdict.py +98 -98
- package/scripts/test_vision.py +228 -228
- package/serve.py +3904 -3662
- package/suite/mine.py +154 -154
- package/suite/pace.py +145 -145
- package/suite/run.py +1046 -1040
- 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 -424
- package/ui/bench.js +1756 -1555
- package/ui/themes/dark.css +35 -35
- package/ui/themes/hardwood.css +52 -52
- package/ui/themes/rainforest.css +32 -32
package/metrics/endpoints.py
CHANGED
|
@@ -1,436 +1,436 @@
|
|
|
1
|
-
"""Where models are served from: Open WebUI, and any other OpenAI-compatible endpoint.
|
|
2
|
-
|
|
3
|
-
python metrics/endpoints.py # each endpoint, whether its key is set, what it serves
|
|
4
|
-
|
|
5
|
-
`config/endpoints.json` names each endpoint:
|
|
6
|
-
|
|
7
|
-
{"openwebui": {"base": "http://localhost:3000/api", "key": "openwebui", "default": true,
|
|
8
|
-
"kind": "hub", "hub": "openwebui"},
|
|
9
|
-
"openrouter": {"base": "https://openrouter.ai/api/v1", "key": "openrouter", "kind": "aggregator",
|
|
10
|
-
"headers": {"HTTP-Referer": "https://example.com", "X-Title": "My app"}}}
|
|
11
|
-
|
|
12
|
-
`key` is the NAME of a key in the llm CLI's own `keys.json` -- the same store
|
|
13
|
-
`llm keys set <name>` writes. The value never enters this repository.
|
|
14
|
-
|
|
15
|
-
Until 2026-10-15 the Open WebUI address was typed into seven files, and a model
|
|
16
|
-
from anywhere else needed a hand-built bypass (`direct-endpoints.json`, for the
|
|
17
|
-
inkling pair). Now every registration carries its endpoint's base and key name
|
|
18
|
-
in the llm yaml, so `llm -m` reaches any of them the same way, and everything
|
|
19
|
-
that asks "what is served" asks every endpoint here.
|
|
20
|
-
|
|
21
|
-
`decisions` (optional) is the full URL of the endpoint's Decisions API, for
|
|
22
|
-
decision models that cannot be reached through chat completions; see
|
|
23
|
-
`metrics/decide.py`. Open WebUI's is its `/openai/{path}` passthrough, which
|
|
24
|
-
answers only when the server runs with ENABLE_OPENAI_API_PASSTHROUGH=True;
|
|
25
|
-
`{path}` is filled per model (`decisions` for Inception, `systemone` for
|
|
26
|
-
Ollama), because the passthrough appends it to the connection's own base.
|
|
27
|
-
|
|
28
|
-
`unlisted_models` (optional) are ids the endpoint serves but leaves out of its
|
|
29
|
-
/models list, so the picker and onboarding can still see them.
|
|
30
|
-
|
|
31
|
-
`headers` go into every yaml entry registered on that endpoint. They exist for
|
|
32
|
-
OpenRouter's attribution gate (some free models answer only named apps), and
|
|
33
|
-
must describe the client that really makes the requests.
|
|
34
|
-
|
|
35
|
-
`kind` is one of KINDS (RELEASE-PLAN section 2, "Connection kinds"), and a
|
|
36
|
-
hub also names which hub it is in `hub` (one of HUBS). It decides what runs
|
|
37
|
-
for an endpoint: the Open WebUI code (owui.py, provider_tags.py, reprefix.py,
|
|
38
|
-
the passthrough hints) only for a hub that is Open WebUI, and the daily served
|
|
39
|
-
check records a whole list only where the user chose what is in it. Ask
|
|
40
|
-
`kind()` and `hub()`, never the entry: an entry written before `kind` existed
|
|
41
|
-
is inferred (see `kind`).
|
|
42
|
-
|
|
43
|
-
A hub adds a model through its adapter (`adapter()`: owui.py, litellm_hub.py,
|
|
44
|
-
cliproxy.py), which calls the hub's admin API with the endpoint's ADMIN key:
|
|
45
|
-
`admin_key` names it, by default `<key>-admin` (so the environment's
|
|
46
|
-
BENCHWARMERS_KEY_<KEY>_ADMIN), the value kept like any other key. Open WebUI
|
|
47
|
-
and LiteLLM fall back to the endpoint's own key, which is an admin's (Open
|
|
48
|
-
WebUI) or often the master key (LiteLLM); CLI Proxy API's management key is
|
|
49
|
-
never one of its API keys, so it has no fallback.
|
|
50
|
-
"""
|
|
51
|
-
|
|
52
|
-
from __future__ import annotations
|
|
53
|
-
|
|
54
|
-
import json
|
|
55
|
-
import os
|
|
56
|
-
import pathlib
|
|
57
|
-
import re
|
|
58
|
-
import sys
|
|
59
|
-
import urllib.parse
|
|
60
|
-
import urllib.request
|
|
61
|
-
|
|
62
|
-
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
63
|
-
from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
|
|
64
|
-
FILE = DATA / "config" / "endpoints.json"
|
|
65
|
-
YAML = DATA / "config" / "extra-openai-models.yaml"
|
|
66
|
-
CONNECTIONS = DATA / "config" / "connections.json"
|
|
67
|
-
def llm_dir() -> pathlib.Path:
|
|
68
|
-
"""The llm CLI's own folder (its keys.json, model registry, options and log),
|
|
69
|
-
as llm finds it: LLM_USER_PATH, else its per-user folder on this OS. Every
|
|
70
|
-
module asks here; until 2026-10-20 four of them spelled out the Windows
|
|
71
|
-
path, so none of them worked on macOS or Linux."""
|
|
72
|
-
if env := os.environ.get("LLM_USER_PATH"):
|
|
73
|
-
return pathlib.Path(env)
|
|
74
|
-
if os.name == "nt":
|
|
75
|
-
return pathlib.Path(os.environ.get("APPDATA") or pathlib.Path.home()) / "io.datasette.llm"
|
|
76
|
-
if sys.platform == "darwin":
|
|
77
|
-
return pathlib.Path.home() / "Library" / "Application Support" / "io.datasette.llm"
|
|
78
|
-
return pathlib.Path(os.environ.get("XDG_CONFIG_HOME") or pathlib.Path.home() / ".config") / "io.datasette.llm"
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
LLM_DIR = llm_dir()
|
|
82
|
-
NAME = re.compile(r"^[a-z0-9][a-z0-9-]{1,31}$")
|
|
83
|
-
# kind -> examples, as the Add form shows them
|
|
84
|
-
KINDS = {"provider": "Groq, Mistral, NVIDIA", "aggregator": "OpenRouter, LLM7, NanoGPT",
|
|
85
|
-
"hub": "Open WebUI, LiteLLM, CLI Proxy API", "local": "Ollama, LM Studio, vLLM"}
|
|
86
|
-
HUBS = {"openwebui": "Open WebUI", "litellm": "LiteLLM", "cliproxy": "CLI Proxy API"}
|
|
87
|
-
# hub -> its adapter module in metrics/; each exposes SUPPORTS, CHECKED,
|
|
88
|
-
# endpoint(), plan(connection, model) and add(connection, model), and the two
|
|
89
|
-
# whose connections only the hub knows also upstreams(), for Discover.
|
|
90
|
-
ADAPTERS = {"openwebui": "owui", "litellm": "litellm_hub", "cliproxy": "cliproxy"}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
def adapter(which: str):
|
|
94
|
-
"""The module that adds a model to a `which` hub. Imported here, on call:
|
|
95
|
-
every adapter imports this module."""
|
|
96
|
-
import importlib
|
|
97
|
-
return importlib.import_module(ADAPTERS[which])
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
def version_note(which: str, version: str, checked: tuple[str, ...]) -> str:
|
|
101
|
-
""""" when a hub runs a release its adapter was checked against ("1.106"
|
|
102
|
-
covers 1.106.x), else a sentence saying so. Advice, not a refusal: these
|
|
103
|
-
admin APIs rarely change between releases, and the plan says what is sent."""
|
|
104
|
-
v = (version or "").strip().lstrip("v")
|
|
105
|
-
if any(v == c or v.startswith(c + ".") for c in checked):
|
|
106
|
-
return ""
|
|
107
|
-
label, known = HUBS.get(which, which), ", ".join(checked)
|
|
108
|
-
return (f"{label} {v} is not a release this adapter was checked against ({known})" if v
|
|
109
|
-
else f"{label} did not say which release it is; this adapter was checked against {known}")
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
def load() -> dict[str, dict]:
|
|
113
|
-
"""Every endpoint; {} on a fresh install (no file yet, or one naming none),
|
|
114
|
-
so the dashboard can render and offer to add the first. A file that cannot
|
|
115
|
-
be read still raises: a writer handed {} for it would save over it.
|
|
116
|
-
|
|
117
|
-
Never a fallback address: until 2026-10-19 a missing or broken file
|
|
118
|
-
silently meant the owner's own Open WebUI on his LAN, which on anyone
|
|
119
|
-
else's machine is a timeout with no explanation. Whatever needs an
|
|
120
|
-
endpoint asks default() (base, key, models and yaml_block all do), and
|
|
121
|
-
that refuses, so a job run before any endpoint exists says what to do."""
|
|
122
|
-
try:
|
|
123
|
-
data = json.loads(FILE.read_text(encoding="utf-8"))
|
|
124
|
-
except FileNotFoundError:
|
|
125
|
-
return {}
|
|
126
|
-
except (OSError, ValueError) as exc:
|
|
127
|
-
raise RuntimeError(f"{FILE} cannot be read: {exc}") from None
|
|
128
|
-
if not isinstance(data, dict):
|
|
129
|
-
raise RuntimeError(f"{FILE} is not a JSON object of endpoints")
|
|
130
|
-
return data
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
def connections() -> dict:
|
|
134
|
-
"""config/connections.json: which connections are paid for, which break
|
|
135
|
-
ties, names for bare-IP hosts, models.dev ids, providers whose free tier
|
|
136
|
-
works only in their own client. {} when absent: a fresh
|
|
137
|
-
install has no such facts, and none are assumed."""
|
|
138
|
-
try:
|
|
139
|
-
data = json.loads(CONNECTIONS.read_text(encoding="utf-8"))
|
|
140
|
-
except (OSError, ValueError):
|
|
141
|
-
return {}
|
|
142
|
-
return data if isinstance(data, dict) else {}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
def default() -> str:
|
|
146
|
-
data = load()
|
|
147
|
-
if not data:
|
|
148
|
-
raise RuntimeError(f"no endpoints configured: add one from the dashboard (python serve.py), run"
|
|
149
|
-
f" scripts/init.py --endpoint NAME BASE_URL, or create {FILE}"
|
|
150
|
-
' like {"name": {"base": "https://.../v1", "key": "name", "default": true}}')
|
|
151
|
-
return next((n for n, e in data.items() if e.get("default")), next(iter(data)))
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
def kind(name: str, data: dict | None = None) -> str:
|
|
155
|
-
"""What an endpoint is: provider, aggregator, hub or local.
|
|
156
|
-
|
|
157
|
-
An entry without `kind` was written before it existed, and the owner's
|
|
158
|
-
whole instance is such entries; nothing rewrites them. It is taken as a
|
|
159
|
-
hub when its base is a server's bare `/api` root, which is where Open
|
|
160
|
-
WebUI serves its OpenAI-compatible API (`/api/models`,
|
|
161
|
-
`/api/chat/completions`) and the shape owui.py already needs to reach the
|
|
162
|
-
admin API; providers, aggregators and the other hubs end in a version or a
|
|
163
|
-
path (`/v1`, `/api/v1`, `/openai/v1`, `/inference`). Not "the default is
|
|
164
|
-
Open WebUI", which the code assumed until now: that would turn a kind-less
|
|
165
|
-
OpenRouter default into a hub and send it Open WebUI's admin calls, and
|
|
166
|
-
would move with Make default. Else local on this machine (Ollama's
|
|
167
|
-
`localhost:11434/v1`), since recorded_whole treats local apart.
|
|
168
|
-
ponytail: anything else without a kind is a provider; nothing treats an
|
|
169
|
-
aggregator apart from a provider yet, so none is guessed from a host."""
|
|
170
|
-
entry = (load() if data is None else data)[name]
|
|
171
|
-
if entry.get("kind") in KINDS:
|
|
172
|
-
return entry["kind"]
|
|
173
|
-
url = urllib.parse.urlsplit(entry.get("base", ""))
|
|
174
|
-
return ("hub" if url.path.rstrip("/").endswith("/api") else
|
|
175
|
-
"local" if url.hostname in ("localhost", "127.0.0.1", "::1") else "provider")
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
def hub(name: str, data: dict | None = None) -> str:
|
|
179
|
-
"""Which hub an endpoint is ("openwebui"), "" when it is not a hub. A hub
|
|
180
|
-
that does not say is Open WebUI, the only one before Phase 5's adapters."""
|
|
181
|
-
data = load() if data is None else data
|
|
182
|
-
return (data[name].get("hub") or "openwebui") if kind(name, data) == "hub" else ""
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
def find_hub(which: str) -> str | None:
|
|
186
|
-
"""The endpoint that is a `which` hub, the default first; None when none is.
|
|
187
|
-
ponytail: one hub of a type; a second Open WebUI is never asked."""
|
|
188
|
-
data = load()
|
|
189
|
-
if not data: # a fresh install has no hub, and no default to ask
|
|
190
|
-
return None
|
|
191
|
-
first = default()
|
|
192
|
-
return next((n for n in sorted(data, key=lambda n: n != first) if hub(n, data) == which), None)
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
def recorded_whole(name: str) -> bool:
|
|
196
|
-
"""Does the daily served check (check_deprecations.py) record everything
|
|
197
|
-
this endpoint lists, or only the models registered on it? Whole only for
|
|
198
|
-
the default, and only when the user chose what is in its list: a hub (what
|
|
199
|
-
was added to Open WebUI) or a local runtime (what was pulled). A provider
|
|
200
|
-
or aggregator lists what it sells, OpenRouter 465 models and NVIDIA about
|
|
201
|
-
180, and the snapshot is not a mirror of anyone's catalogue."""
|
|
202
|
-
return name == default() and kind(name) in ("hub", "local")
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
def base(name: str | None = None) -> str:
|
|
206
|
-
return load()[name or default()]["base"].rstrip("/")
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
ENV_KEY = "BENCHWARMERS_KEY_"
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
def _stored() -> dict:
|
|
213
|
-
try:
|
|
214
|
-
return json.loads((LLM_DIR / "keys.json").read_text(encoding="utf-8"))
|
|
215
|
-
except (OSError, ValueError):
|
|
216
|
-
return {}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
def keys() -> dict:
|
|
220
|
-
"""Every key by name: BENCHWARMERS_KEY_<NAME> from the environment first, so
|
|
221
|
-
a container needs no key store, then the llm CLI's keys.json. <NAME> is the
|
|
222
|
-
key name upper-cased with '-' as '_' (BENCHWARMERS_KEY_OPENROUTER_2 for
|
|
223
|
-
openrouter-2). ponytail: a hand-written key name holding '_' or '.' cannot
|
|
224
|
-
come from the environment; every name the dashboard makes can."""
|
|
225
|
-
env = {var[len(ENV_KEY):].lower().replace("_", "-"): value
|
|
226
|
-
for var, value in os.environ.items() if var.upper().startswith(ENV_KEY) and var[len(ENV_KEY):] and value}
|
|
227
|
-
return {**_stored(), **env}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
def key(name: str | None = None) -> str:
|
|
231
|
-
return keys().get(load()[name or default()].get("key", ""), "")
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
def admin_name(entry: dict) -> str:
|
|
235
|
-
"""The name a hub's admin key is kept under (see the module docstring)."""
|
|
236
|
-
return entry.get("admin_key") or f"{entry.get('key', '')}-admin"
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
def admin_key(name: str, fallback: bool = True) -> str:
|
|
240
|
-
"""A hub's admin key, else (`fallback`) the endpoint's own key."""
|
|
241
|
-
entry, have = load()[name], keys()
|
|
242
|
-
return have.get(admin_name(entry), "") or (have.get(entry.get("key", ""), "") if fallback else "")
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
def set_key(key_name: str, value: str) -> None:
|
|
246
|
-
"""Store a key where `llm keys set` would. Atomic; other keys untouched,
|
|
247
|
-
and never one from the environment."""
|
|
248
|
-
data = _stored()
|
|
249
|
-
data[key_name] = value
|
|
250
|
-
import locked # creates llm's folder: a container's store starts empty
|
|
251
|
-
locked.write_atomic(LLM_DIR / "keys.json", json.dumps(data, indent=2) + "\n")
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
def models(name: str | None = None, timeout: int = 30) -> dict[str, dict]:
|
|
255
|
-
"""served id -> its /models entry. Raises if the endpoint cannot be asked.
|
|
256
|
-
|
|
257
|
-
Plus the endpoint's `unlisted_models`: ids it serves but leaves out of
|
|
258
|
-
/models. OpenRouter lists none of its decision models (Mercury Decide,
|
|
259
|
-
Respan), the same reason they had to be typed into Open WebUI's connection
|
|
260
|
-
by hand. They are taken as served on the config's word, so availability
|
|
261
|
-
cannot notice one going away."""
|
|
262
|
-
entry = load()[name or default()]
|
|
263
|
-
import connect # here: connect imports this module
|
|
264
|
-
connect.fresh(entry.get("key", "")) # an expiring sign-in renewed first; a failure shows as a 401
|
|
265
|
-
live = list_models(entry, key(name), timeout)
|
|
266
|
-
return {**{i: {"id": i} for i in entry.get("unlisted_models", [])}, **live}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
def list_models(entry: dict, secret: str = "", timeout: int = 30) -> dict[str, dict]:
|
|
270
|
-
"""What an endpoint entry lists, saved or not yet (the dashboard tests one
|
|
271
|
-
before saving it). `models_url` when the list lives elsewhere (GitHub
|
|
272
|
-
Models: its catalog, a bare JSON list); entries marked `visibility` other
|
|
273
|
-
than "list" are left out (ChatGPT's plan lists ones it will not serve)."""
|
|
274
|
-
headers = dict(entry.get("headers") or {})
|
|
275
|
-
if secret:
|
|
276
|
-
headers["Authorization"] = f"Bearer {secret}"
|
|
277
|
-
url = entry.get("models_url") or entry["base"].rstrip("/") + "/models"
|
|
278
|
-
with urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=timeout) as response:
|
|
279
|
-
data = json.load(response)
|
|
280
|
-
rows = data.get("data", []) if isinstance(data, dict) else data
|
|
281
|
-
return {e["id"]: e for e in rows
|
|
282
|
-
if isinstance(e, dict) and e.get("id") and e.get("visibility", "list") == "list"}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
def entry_of(alias: str) -> dict:
|
|
286
|
-
"""The endpoints.json entry an alias is registered on, or {}."""
|
|
287
|
-
data = load()
|
|
288
|
-
name = endpoint_name(registrations().get(alias) or {}, data)
|
|
289
|
-
return data.get(name, {}) if name else {}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
def endpoint_name(reg: dict, data: dict | None = None) -> str | None:
|
|
293
|
-
"""The endpoint a registration is on, by base AND key name: one provider
|
|
294
|
-
can be two endpoints (a pasted key and a sign-in, or two accounts) on the
|
|
295
|
-
same base, but each endpoint has its own key. `data`: load(), if in hand."""
|
|
296
|
-
data = load() if data is None else data
|
|
297
|
-
want = (reg.get("api_base") or "").rstrip("/")
|
|
298
|
-
same = [n for n, e in data.items() if e["base"].rstrip("/") == want]
|
|
299
|
-
return next((n for n in same if data[n].get("key") == reg.get("api_key_name")), same[0] if same else None)
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
def registrations() -> dict[str, dict]:
|
|
303
|
-
"""alias -> its yaml entry as written by yaml_block: model_name, api_base,
|
|
304
|
-
api_key_name and a `headers` dict. Not a YAML parser; the file is ours."""
|
|
305
|
-
out: dict[str, dict] = {}
|
|
306
|
-
current = None
|
|
307
|
-
if not YAML.exists(): # a fresh install has registered nothing yet
|
|
308
|
-
return out
|
|
309
|
-
for line in YAML.read_text(encoding="utf-8").splitlines():
|
|
310
|
-
if (m := re.match(r"\s*-\s*model_id:\s*(.+)", line)):
|
|
311
|
-
current = out.setdefault(m.group(1).strip().strip("\"'"), {"headers": {}})
|
|
312
|
-
elif current is None or not line.strip() or line.lstrip().startswith("#"):
|
|
313
|
-
continue
|
|
314
|
-
elif (m := re.match(r" (\S[^:]*):\s*(.+)", line)): # under headers:
|
|
315
|
-
current["headers"][m.group(1)] = m.group(2).strip().strip("\"'")
|
|
316
|
-
elif (m := re.match(r" (\w+):\s*(.*)", line)) and m.group(1) != "headers":
|
|
317
|
-
current[m.group(1)] = m.group(2).strip().strip("\"'")
|
|
318
|
-
return out
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
def registry() -> list[dict]:
|
|
322
|
-
"""Every yaml registration: alias, served name, and the endpoint it is on."""
|
|
323
|
-
data = load()
|
|
324
|
-
return [{"alias": a, "served": r.get("model_name", ""), "endpoint": endpoint_name(r, data) or "?"}
|
|
325
|
-
for a, r in registrations().items()]
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
def snapshot_key(served: str, endpoint: str) -> str:
|
|
329
|
-
"""How availability records name a model: bare on the default endpoint
|
|
330
|
-
(every record before 2026-10-15), `served@endpoint` elsewhere, because the
|
|
331
|
-
same id can be served by two endpoints and they can go away separately."""
|
|
332
|
-
return served if endpoint == default() else f"{served}@{endpoint}"
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
def yaml_block(alias: str, served: str, endpoint: str | None = None) -> str:
|
|
336
|
-
entry = load()[endpoint or default()]
|
|
337
|
-
quoted = f'"{served}"' if any(c in served for c in ":/@") else served
|
|
338
|
-
lines = [f"- model_id: {alias}", f" model_name: {quoted}",
|
|
339
|
-
f' api_base: "{entry["base"]}"', f" api_key_name: {entry['key']}"]
|
|
340
|
-
if entry.get("headers"):
|
|
341
|
-
lines.append(" headers:")
|
|
342
|
-
lines += [f' {k}: "{v}"' for k, v in entry["headers"].items()]
|
|
343
|
-
return "\n" + "\n".join(lines) + "\n"
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
def main() -> None:
|
|
347
|
-
for name, entry in load().items():
|
|
348
|
-
try:
|
|
349
|
-
n = f"{len(models(name))} served"
|
|
350
|
-
except Exception as exc: # noqa: BLE001
|
|
351
|
-
n = f"unreachable ({type(exc).__name__})"
|
|
352
|
-
mark = " (default)" if name == default() else ""
|
|
353
|
-
print(f" {name}{mark}: {entry['base']} key {'set' if key(name) else 'MISSING'} {n}")
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
def _selftest() -> None:
|
|
357
|
-
"""No endpoint means a refusal where one is needed, not someone's LAN; the
|
|
358
|
-
yaml parser keeps headers."""
|
|
359
|
-
import tempfile
|
|
360
|
-
global FILE, YAML, LLM_DIR
|
|
361
|
-
real = FILE, YAML, LLM_DIR
|
|
362
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
363
|
-
FILE, YAML = pathlib.Path(tmp) / "endpoints.json", pathlib.Path(tmp) / "models.yaml"
|
|
364
|
-
LLM_DIR = pathlib.Path(tmp) / "llm" # absent: set_key makes it
|
|
365
|
-
try:
|
|
366
|
-
set_key("or-2", "stored")
|
|
367
|
-
os.environ[ENV_KEY + "OR_2"], os.environ[ENV_KEY + "NEW"] = "from-env", "env-only"
|
|
368
|
-
assert keys()["or-2"] == "from-env" and keys()["new"] == "env-only"
|
|
369
|
-
set_key("other", "x") # the environment's never reach the file
|
|
370
|
-
assert _stored() == {"or-2": "stored", "other": "x"}, _stored()
|
|
371
|
-
finally:
|
|
372
|
-
os.environ.pop(ENV_KEY + "OR_2", None), os.environ.pop(ENV_KEY + "NEW", None)
|
|
373
|
-
try:
|
|
374
|
-
for content in (None, "{}", "not json", "[]"):
|
|
375
|
-
if content is not None:
|
|
376
|
-
FILE.write_text(content, encoding="utf-8")
|
|
377
|
-
fresh = content in (None, "{}") # a fresh install: nothing yet, not damage
|
|
378
|
-
try:
|
|
379
|
-
assert load() == {} and fresh, f"load() accepted {content!r}"
|
|
380
|
-
except RuntimeError:
|
|
381
|
-
assert not fresh, f"load() refused {content!r}"
|
|
382
|
-
try:
|
|
383
|
-
default()
|
|
384
|
-
raise AssertionError(f"default() accepted {content!r}")
|
|
385
|
-
except RuntimeError:
|
|
386
|
-
pass
|
|
387
|
-
FILE.unlink()
|
|
388
|
-
assert registry() == [], "no yaml, no endpoints: nothing registered"
|
|
389
|
-
FILE.write_text('{"or": {"base": "https://x.example/v1/", "key": "or"}}', encoding="utf-8")
|
|
390
|
-
YAML.write_text(yaml_block("m1", "v/m:free", "or").replace(
|
|
391
|
-
" api_key_name: or", ' api_key_name: or\n headers:\n X-Title: "t"'), encoding="utf-8")
|
|
392
|
-
r = registrations()["m1"]
|
|
393
|
-
assert (r["model_name"], r["api_key_name"], r["headers"]) == ("v/m:free", "or", {"X-Title": "t"}), r
|
|
394
|
-
assert registry() == [{"alias": "m1", "served": "v/m:free", "endpoint": "or"}], registry()
|
|
395
|
-
assert entry_of("m1")["key"] == "or" and entry_of("nobody") == {}
|
|
396
|
-
# One provider, two endpoints on one base: each model stays with its own.
|
|
397
|
-
FILE.write_text('{"or": {"base": "https://x.example/v1/", "key": "or"},'
|
|
398
|
-
' "or-2": {"base": "https://x.example/v1", "key": "or-2"}}', encoding="utf-8")
|
|
399
|
-
YAML.write_text(yaml_block("m1", "a", "or") + yaml_block("m2", "a", "or-2"), encoding="utf-8")
|
|
400
|
-
assert {r["alias"]: r["endpoint"] for r in registry()} == {"m1": "or", "m2": "or-2"}, registry()
|
|
401
|
-
assert entry_of("m2")["key"] == "or-2"
|
|
402
|
-
# Kind: what an entry says, else a hub at a bare /api root (Open WebUI's), else a provider.
|
|
403
|
-
FILE.write_text(json.dumps({
|
|
404
|
-
"owui": {"base": "http://hub.example:3000/api/", "key": "a"},
|
|
405
|
-
"or": {"base": "https://x.example/api/v1", "key": "b", "default": True},
|
|
406
|
-
"lite": {"base": "http://lite.example/v1", "key": "c", "kind": "hub", "hub": "litellm"},
|
|
407
|
-
"pulled": {"base": "http://localhost:11434/v1", "key": "d"},
|
|
408
|
-
"said": {"base": "http://said.example/api", "key": "e", "kind": "aggregator"}}), encoding="utf-8")
|
|
409
|
-
got = [(kind(n), hub(n)) for n in load()]
|
|
410
|
-
assert got == [("hub", "openwebui"), ("provider", ""), ("hub", "litellm"), ("local", ""),
|
|
411
|
-
("aggregator", "")], got
|
|
412
|
-
assert find_hub("openwebui") == "owui" and find_hub("cliproxy") is None
|
|
413
|
-
# A hub's admin key: <key>-admin, else its own key unless the hub has none to fall back on.
|
|
414
|
-
set_key("c", "api")
|
|
415
|
-
assert admin_key("lite") == "api" and admin_key("lite", fallback=False) == ""
|
|
416
|
-
set_key("c-admin", "master")
|
|
417
|
-
assert admin_key("lite") == "master" == admin_key("lite", fallback=False)
|
|
418
|
-
assert version_note("litellm", "v1.106.3", ("1.106",)) == ""
|
|
419
|
-
assert "1.107.0" in version_note("litellm", "1.107.0", ("1.106",)) and version_note("litellm", "", ("1",))
|
|
420
|
-
assert not recorded_whole("or") and not recorded_whole("pulled") # an aggregator default; not the default
|
|
421
|
-
FILE.write_text(json.dumps({**load(), "or": {"base": "https://x.example/api/v1", "key": "b"},
|
|
422
|
-
"pulled": {"base": "http://localhost:11434/v1", "default": True}}),
|
|
423
|
-
encoding="utf-8")
|
|
424
|
-
assert recorded_whole("pulled") and not recorded_whole("owui")
|
|
425
|
-
# A list as an object or a bare list (GitHub's catalog); hidden ones left out.
|
|
426
|
-
listing = pathlib.Path(tmp) / "models.json"
|
|
427
|
-
for body in ('{"data": [{"id": "a"}, {"id": "b", "visibility": "hide"}]}', '[{"id": "a"}]'):
|
|
428
|
-
listing.write_text(body, encoding="utf-8")
|
|
429
|
-
assert list(list_models({"base": "x", "models_url": listing.as_uri()})) == ["a"], body
|
|
430
|
-
finally:
|
|
431
|
-
FILE, YAML, LLM_DIR = real
|
|
432
|
-
print("ok")
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
if __name__ == "__main__":
|
|
436
|
-
_selftest() if "--selftest" in sys.argv else main()
|
|
1
|
+
"""Where models are served from: Open WebUI, and any other OpenAI-compatible endpoint.
|
|
2
|
+
|
|
3
|
+
python metrics/endpoints.py # each endpoint, whether its key is set, what it serves
|
|
4
|
+
|
|
5
|
+
`config/endpoints.json` names each endpoint:
|
|
6
|
+
|
|
7
|
+
{"openwebui": {"base": "http://localhost:3000/api", "key": "openwebui", "default": true,
|
|
8
|
+
"kind": "hub", "hub": "openwebui"},
|
|
9
|
+
"openrouter": {"base": "https://openrouter.ai/api/v1", "key": "openrouter", "kind": "aggregator",
|
|
10
|
+
"headers": {"HTTP-Referer": "https://example.com", "X-Title": "My app"}}}
|
|
11
|
+
|
|
12
|
+
`key` is the NAME of a key in the llm CLI's own `keys.json` -- the same store
|
|
13
|
+
`llm keys set <name>` writes. The value never enters this repository.
|
|
14
|
+
|
|
15
|
+
Until 2026-10-15 the Open WebUI address was typed into seven files, and a model
|
|
16
|
+
from anywhere else needed a hand-built bypass (`direct-endpoints.json`, for the
|
|
17
|
+
inkling pair). Now every registration carries its endpoint's base and key name
|
|
18
|
+
in the llm yaml, so `llm -m` reaches any of them the same way, and everything
|
|
19
|
+
that asks "what is served" asks every endpoint here.
|
|
20
|
+
|
|
21
|
+
`decisions` (optional) is the full URL of the endpoint's Decisions API, for
|
|
22
|
+
decision models that cannot be reached through chat completions; see
|
|
23
|
+
`metrics/decide.py`. Open WebUI's is its `/openai/{path}` passthrough, which
|
|
24
|
+
answers only when the server runs with ENABLE_OPENAI_API_PASSTHROUGH=True;
|
|
25
|
+
`{path}` is filled per model (`decisions` for Inception, `systemone` for
|
|
26
|
+
Ollama), because the passthrough appends it to the connection's own base.
|
|
27
|
+
|
|
28
|
+
`unlisted_models` (optional) are ids the endpoint serves but leaves out of its
|
|
29
|
+
/models list, so the picker and onboarding can still see them.
|
|
30
|
+
|
|
31
|
+
`headers` go into every yaml entry registered on that endpoint. They exist for
|
|
32
|
+
OpenRouter's attribution gate (some free models answer only named apps), and
|
|
33
|
+
must describe the client that really makes the requests.
|
|
34
|
+
|
|
35
|
+
`kind` is one of KINDS (RELEASE-PLAN section 2, "Connection kinds"), and a
|
|
36
|
+
hub also names which hub it is in `hub` (one of HUBS). It decides what runs
|
|
37
|
+
for an endpoint: the Open WebUI code (owui.py, provider_tags.py, reprefix.py,
|
|
38
|
+
the passthrough hints) only for a hub that is Open WebUI, and the daily served
|
|
39
|
+
check records a whole list only where the user chose what is in it. Ask
|
|
40
|
+
`kind()` and `hub()`, never the entry: an entry written before `kind` existed
|
|
41
|
+
is inferred (see `kind`).
|
|
42
|
+
|
|
43
|
+
A hub adds a model through its adapter (`adapter()`: owui.py, litellm_hub.py,
|
|
44
|
+
cliproxy.py), which calls the hub's admin API with the endpoint's ADMIN key:
|
|
45
|
+
`admin_key` names it, by default `<key>-admin` (so the environment's
|
|
46
|
+
BENCHWARMERS_KEY_<KEY>_ADMIN), the value kept like any other key. Open WebUI
|
|
47
|
+
and LiteLLM fall back to the endpoint's own key, which is an admin's (Open
|
|
48
|
+
WebUI) or often the master key (LiteLLM); CLI Proxy API's management key is
|
|
49
|
+
never one of its API keys, so it has no fallback.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
|
|
54
|
+
import json
|
|
55
|
+
import os
|
|
56
|
+
import pathlib
|
|
57
|
+
import re
|
|
58
|
+
import sys
|
|
59
|
+
import urllib.parse
|
|
60
|
+
import urllib.request
|
|
61
|
+
|
|
62
|
+
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
63
|
+
from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
|
|
64
|
+
FILE = DATA / "config" / "endpoints.json"
|
|
65
|
+
YAML = DATA / "config" / "extra-openai-models.yaml"
|
|
66
|
+
CONNECTIONS = DATA / "config" / "connections.json"
|
|
67
|
+
def llm_dir() -> pathlib.Path:
|
|
68
|
+
"""The llm CLI's own folder (its keys.json, model registry, options and log),
|
|
69
|
+
as llm finds it: LLM_USER_PATH, else its per-user folder on this OS. Every
|
|
70
|
+
module asks here; until 2026-10-20 four of them spelled out the Windows
|
|
71
|
+
path, so none of them worked on macOS or Linux."""
|
|
72
|
+
if env := os.environ.get("LLM_USER_PATH"):
|
|
73
|
+
return pathlib.Path(env)
|
|
74
|
+
if os.name == "nt":
|
|
75
|
+
return pathlib.Path(os.environ.get("APPDATA") or pathlib.Path.home()) / "io.datasette.llm"
|
|
76
|
+
if sys.platform == "darwin":
|
|
77
|
+
return pathlib.Path.home() / "Library" / "Application Support" / "io.datasette.llm"
|
|
78
|
+
return pathlib.Path(os.environ.get("XDG_CONFIG_HOME") or pathlib.Path.home() / ".config") / "io.datasette.llm"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
LLM_DIR = llm_dir()
|
|
82
|
+
NAME = re.compile(r"^[a-z0-9][a-z0-9-]{1,31}$")
|
|
83
|
+
# kind -> examples, as the Add form shows them
|
|
84
|
+
KINDS = {"provider": "Groq, Mistral, NVIDIA", "aggregator": "OpenRouter, LLM7, NanoGPT",
|
|
85
|
+
"hub": "Open WebUI, LiteLLM, CLI Proxy API", "local": "Ollama, LM Studio, vLLM"}
|
|
86
|
+
HUBS = {"openwebui": "Open WebUI", "litellm": "LiteLLM", "cliproxy": "CLI Proxy API"}
|
|
87
|
+
# hub -> its adapter module in metrics/; each exposes SUPPORTS, CHECKED,
|
|
88
|
+
# endpoint(), plan(connection, model) and add(connection, model), and the two
|
|
89
|
+
# whose connections only the hub knows also upstreams(), for Discover.
|
|
90
|
+
ADAPTERS = {"openwebui": "owui", "litellm": "litellm_hub", "cliproxy": "cliproxy"}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def adapter(which: str):
|
|
94
|
+
"""The module that adds a model to a `which` hub. Imported here, on call:
|
|
95
|
+
every adapter imports this module."""
|
|
96
|
+
import importlib
|
|
97
|
+
return importlib.import_module(ADAPTERS[which])
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def version_note(which: str, version: str, checked: tuple[str, ...]) -> str:
|
|
101
|
+
""""" when a hub runs a release its adapter was checked against ("1.106"
|
|
102
|
+
covers 1.106.x), else a sentence saying so. Advice, not a refusal: these
|
|
103
|
+
admin APIs rarely change between releases, and the plan says what is sent."""
|
|
104
|
+
v = (version or "").strip().lstrip("v")
|
|
105
|
+
if any(v == c or v.startswith(c + ".") for c in checked):
|
|
106
|
+
return ""
|
|
107
|
+
label, known = HUBS.get(which, which), ", ".join(checked)
|
|
108
|
+
return (f"{label} {v} is not a release this adapter was checked against ({known})" if v
|
|
109
|
+
else f"{label} did not say which release it is; this adapter was checked against {known}")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def load() -> dict[str, dict]:
|
|
113
|
+
"""Every endpoint; {} on a fresh install (no file yet, or one naming none),
|
|
114
|
+
so the dashboard can render and offer to add the first. A file that cannot
|
|
115
|
+
be read still raises: a writer handed {} for it would save over it.
|
|
116
|
+
|
|
117
|
+
Never a fallback address: until 2026-10-19 a missing or broken file
|
|
118
|
+
silently meant the owner's own Open WebUI on his LAN, which on anyone
|
|
119
|
+
else's machine is a timeout with no explanation. Whatever needs an
|
|
120
|
+
endpoint asks default() (base, key, models and yaml_block all do), and
|
|
121
|
+
that refuses, so a job run before any endpoint exists says what to do."""
|
|
122
|
+
try:
|
|
123
|
+
data = json.loads(FILE.read_text(encoding="utf-8"))
|
|
124
|
+
except FileNotFoundError:
|
|
125
|
+
return {}
|
|
126
|
+
except (OSError, ValueError) as exc:
|
|
127
|
+
raise RuntimeError(f"{FILE} cannot be read: {exc}") from None
|
|
128
|
+
if not isinstance(data, dict):
|
|
129
|
+
raise RuntimeError(f"{FILE} is not a JSON object of endpoints")
|
|
130
|
+
return data
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def connections() -> dict:
|
|
134
|
+
"""config/connections.json: which connections are paid for, which break
|
|
135
|
+
ties, names for bare-IP hosts, models.dev ids, providers whose free tier
|
|
136
|
+
works only in their own client. {} when absent: a fresh
|
|
137
|
+
install has no such facts, and none are assumed."""
|
|
138
|
+
try:
|
|
139
|
+
data = json.loads(CONNECTIONS.read_text(encoding="utf-8"))
|
|
140
|
+
except (OSError, ValueError):
|
|
141
|
+
return {}
|
|
142
|
+
return data if isinstance(data, dict) else {}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def default() -> str:
|
|
146
|
+
data = load()
|
|
147
|
+
if not data:
|
|
148
|
+
raise RuntimeError(f"no endpoints configured: add one from the dashboard (python serve.py), run"
|
|
149
|
+
f" scripts/init.py --endpoint NAME BASE_URL, or create {FILE}"
|
|
150
|
+
' like {"name": {"base": "https://.../v1", "key": "name", "default": true}}')
|
|
151
|
+
return next((n for n, e in data.items() if e.get("default")), next(iter(data)))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def kind(name: str, data: dict | None = None) -> str:
|
|
155
|
+
"""What an endpoint is: provider, aggregator, hub or local.
|
|
156
|
+
|
|
157
|
+
An entry without `kind` was written before it existed, and the owner's
|
|
158
|
+
whole instance is such entries; nothing rewrites them. It is taken as a
|
|
159
|
+
hub when its base is a server's bare `/api` root, which is where Open
|
|
160
|
+
WebUI serves its OpenAI-compatible API (`/api/models`,
|
|
161
|
+
`/api/chat/completions`) and the shape owui.py already needs to reach the
|
|
162
|
+
admin API; providers, aggregators and the other hubs end in a version or a
|
|
163
|
+
path (`/v1`, `/api/v1`, `/openai/v1`, `/inference`). Not "the default is
|
|
164
|
+
Open WebUI", which the code assumed until now: that would turn a kind-less
|
|
165
|
+
OpenRouter default into a hub and send it Open WebUI's admin calls, and
|
|
166
|
+
would move with Make default. Else local on this machine (Ollama's
|
|
167
|
+
`localhost:11434/v1`), since recorded_whole treats local apart.
|
|
168
|
+
ponytail: anything else without a kind is a provider; nothing treats an
|
|
169
|
+
aggregator apart from a provider yet, so none is guessed from a host."""
|
|
170
|
+
entry = (load() if data is None else data)[name]
|
|
171
|
+
if entry.get("kind") in KINDS:
|
|
172
|
+
return entry["kind"]
|
|
173
|
+
url = urllib.parse.urlsplit(entry.get("base", ""))
|
|
174
|
+
return ("hub" if url.path.rstrip("/").endswith("/api") else
|
|
175
|
+
"local" if url.hostname in ("localhost", "127.0.0.1", "::1") else "provider")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def hub(name: str, data: dict | None = None) -> str:
|
|
179
|
+
"""Which hub an endpoint is ("openwebui"), "" when it is not a hub. A hub
|
|
180
|
+
that does not say is Open WebUI, the only one before Phase 5's adapters."""
|
|
181
|
+
data = load() if data is None else data
|
|
182
|
+
return (data[name].get("hub") or "openwebui") if kind(name, data) == "hub" else ""
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def find_hub(which: str) -> str | None:
|
|
186
|
+
"""The endpoint that is a `which` hub, the default first; None when none is.
|
|
187
|
+
ponytail: one hub of a type; a second Open WebUI is never asked."""
|
|
188
|
+
data = load()
|
|
189
|
+
if not data: # a fresh install has no hub, and no default to ask
|
|
190
|
+
return None
|
|
191
|
+
first = default()
|
|
192
|
+
return next((n for n in sorted(data, key=lambda n: n != first) if hub(n, data) == which), None)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def recorded_whole(name: str) -> bool:
|
|
196
|
+
"""Does the daily served check (check_deprecations.py) record everything
|
|
197
|
+
this endpoint lists, or only the models registered on it? Whole only for
|
|
198
|
+
the default, and only when the user chose what is in its list: a hub (what
|
|
199
|
+
was added to Open WebUI) or a local runtime (what was pulled). A provider
|
|
200
|
+
or aggregator lists what it sells, OpenRouter 465 models and NVIDIA about
|
|
201
|
+
180, and the snapshot is not a mirror of anyone's catalogue."""
|
|
202
|
+
return name == default() and kind(name) in ("hub", "local")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def base(name: str | None = None) -> str:
|
|
206
|
+
return load()[name or default()]["base"].rstrip("/")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
ENV_KEY = "BENCHWARMERS_KEY_"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _stored() -> dict:
|
|
213
|
+
try:
|
|
214
|
+
return json.loads((LLM_DIR / "keys.json").read_text(encoding="utf-8"))
|
|
215
|
+
except (OSError, ValueError):
|
|
216
|
+
return {}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def keys() -> dict:
|
|
220
|
+
"""Every key by name: BENCHWARMERS_KEY_<NAME> from the environment first, so
|
|
221
|
+
a container needs no key store, then the llm CLI's keys.json. <NAME> is the
|
|
222
|
+
key name upper-cased with '-' as '_' (BENCHWARMERS_KEY_OPENROUTER_2 for
|
|
223
|
+
openrouter-2). ponytail: a hand-written key name holding '_' or '.' cannot
|
|
224
|
+
come from the environment; every name the dashboard makes can."""
|
|
225
|
+
env = {var[len(ENV_KEY):].lower().replace("_", "-"): value
|
|
226
|
+
for var, value in os.environ.items() if var.upper().startswith(ENV_KEY) and var[len(ENV_KEY):] and value}
|
|
227
|
+
return {**_stored(), **env}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def key(name: str | None = None) -> str:
|
|
231
|
+
return keys().get(load()[name or default()].get("key", ""), "")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def admin_name(entry: dict) -> str:
|
|
235
|
+
"""The name a hub's admin key is kept under (see the module docstring)."""
|
|
236
|
+
return entry.get("admin_key") or f"{entry.get('key', '')}-admin"
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def admin_key(name: str, fallback: bool = True) -> str:
|
|
240
|
+
"""A hub's admin key, else (`fallback`) the endpoint's own key."""
|
|
241
|
+
entry, have = load()[name], keys()
|
|
242
|
+
return have.get(admin_name(entry), "") or (have.get(entry.get("key", ""), "") if fallback else "")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def set_key(key_name: str, value: str) -> None:
|
|
246
|
+
"""Store a key where `llm keys set` would. Atomic; other keys untouched,
|
|
247
|
+
and never one from the environment."""
|
|
248
|
+
data = _stored()
|
|
249
|
+
data[key_name] = value
|
|
250
|
+
import locked # creates llm's folder: a container's store starts empty
|
|
251
|
+
locked.write_atomic(LLM_DIR / "keys.json", json.dumps(data, indent=2) + "\n")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def models(name: str | None = None, timeout: int = 30) -> dict[str, dict]:
|
|
255
|
+
"""served id -> its /models entry. Raises if the endpoint cannot be asked.
|
|
256
|
+
|
|
257
|
+
Plus the endpoint's `unlisted_models`: ids it serves but leaves out of
|
|
258
|
+
/models. OpenRouter lists none of its decision models (Mercury Decide,
|
|
259
|
+
Respan), the same reason they had to be typed into Open WebUI's connection
|
|
260
|
+
by hand. They are taken as served on the config's word, so availability
|
|
261
|
+
cannot notice one going away."""
|
|
262
|
+
entry = load()[name or default()]
|
|
263
|
+
import connect # here: connect imports this module
|
|
264
|
+
connect.fresh(entry.get("key", "")) # an expiring sign-in renewed first; a failure shows as a 401
|
|
265
|
+
live = list_models(entry, key(name), timeout)
|
|
266
|
+
return {**{i: {"id": i} for i in entry.get("unlisted_models", [])}, **live}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def list_models(entry: dict, secret: str = "", timeout: int = 30) -> dict[str, dict]:
|
|
270
|
+
"""What an endpoint entry lists, saved or not yet (the dashboard tests one
|
|
271
|
+
before saving it). `models_url` when the list lives elsewhere (GitHub
|
|
272
|
+
Models: its catalog, a bare JSON list); entries marked `visibility` other
|
|
273
|
+
than "list" are left out (ChatGPT's plan lists ones it will not serve)."""
|
|
274
|
+
headers = dict(entry.get("headers") or {})
|
|
275
|
+
if secret:
|
|
276
|
+
headers["Authorization"] = f"Bearer {secret}"
|
|
277
|
+
url = entry.get("models_url") or entry["base"].rstrip("/") + "/models"
|
|
278
|
+
with urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=timeout) as response:
|
|
279
|
+
data = json.load(response)
|
|
280
|
+
rows = data.get("data", []) if isinstance(data, dict) else data
|
|
281
|
+
return {e["id"]: e for e in rows
|
|
282
|
+
if isinstance(e, dict) and e.get("id") and e.get("visibility", "list") == "list"}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def entry_of(alias: str) -> dict:
|
|
286
|
+
"""The endpoints.json entry an alias is registered on, or {}."""
|
|
287
|
+
data = load()
|
|
288
|
+
name = endpoint_name(registrations().get(alias) or {}, data)
|
|
289
|
+
return data.get(name, {}) if name else {}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def endpoint_name(reg: dict, data: dict | None = None) -> str | None:
|
|
293
|
+
"""The endpoint a registration is on, by base AND key name: one provider
|
|
294
|
+
can be two endpoints (a pasted key and a sign-in, or two accounts) on the
|
|
295
|
+
same base, but each endpoint has its own key. `data`: load(), if in hand."""
|
|
296
|
+
data = load() if data is None else data
|
|
297
|
+
want = (reg.get("api_base") or "").rstrip("/")
|
|
298
|
+
same = [n for n, e in data.items() if e["base"].rstrip("/") == want]
|
|
299
|
+
return next((n for n in same if data[n].get("key") == reg.get("api_key_name")), same[0] if same else None)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def registrations() -> dict[str, dict]:
|
|
303
|
+
"""alias -> its yaml entry as written by yaml_block: model_name, api_base,
|
|
304
|
+
api_key_name and a `headers` dict. Not a YAML parser; the file is ours."""
|
|
305
|
+
out: dict[str, dict] = {}
|
|
306
|
+
current = None
|
|
307
|
+
if not YAML.exists(): # a fresh install has registered nothing yet
|
|
308
|
+
return out
|
|
309
|
+
for line in YAML.read_text(encoding="utf-8").splitlines():
|
|
310
|
+
if (m := re.match(r"\s*-\s*model_id:\s*(.+)", line)):
|
|
311
|
+
current = out.setdefault(m.group(1).strip().strip("\"'"), {"headers": {}})
|
|
312
|
+
elif current is None or not line.strip() or line.lstrip().startswith("#"):
|
|
313
|
+
continue
|
|
314
|
+
elif (m := re.match(r" (\S[^:]*):\s*(.+)", line)): # under headers:
|
|
315
|
+
current["headers"][m.group(1)] = m.group(2).strip().strip("\"'")
|
|
316
|
+
elif (m := re.match(r" (\w+):\s*(.*)", line)) and m.group(1) != "headers":
|
|
317
|
+
current[m.group(1)] = m.group(2).strip().strip("\"'")
|
|
318
|
+
return out
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def registry() -> list[dict]:
|
|
322
|
+
"""Every yaml registration: alias, served name, and the endpoint it is on."""
|
|
323
|
+
data = load()
|
|
324
|
+
return [{"alias": a, "served": r.get("model_name", ""), "endpoint": endpoint_name(r, data) or "?"}
|
|
325
|
+
for a, r in registrations().items()]
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def snapshot_key(served: str, endpoint: str) -> str:
|
|
329
|
+
"""How availability records name a model: bare on the default endpoint
|
|
330
|
+
(every record before 2026-10-15), `served@endpoint` elsewhere, because the
|
|
331
|
+
same id can be served by two endpoints and they can go away separately."""
|
|
332
|
+
return served if endpoint == default() else f"{served}@{endpoint}"
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def yaml_block(alias: str, served: str, endpoint: str | None = None) -> str:
|
|
336
|
+
entry = load()[endpoint or default()]
|
|
337
|
+
quoted = f'"{served}"' if any(c in served for c in ":/@") else served
|
|
338
|
+
lines = [f"- model_id: {alias}", f" model_name: {quoted}",
|
|
339
|
+
f' api_base: "{entry["base"]}"', f" api_key_name: {entry['key']}"]
|
|
340
|
+
if entry.get("headers"):
|
|
341
|
+
lines.append(" headers:")
|
|
342
|
+
lines += [f' {k}: "{v}"' for k, v in entry["headers"].items()]
|
|
343
|
+
return "\n" + "\n".join(lines) + "\n"
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def main() -> None:
|
|
347
|
+
for name, entry in load().items():
|
|
348
|
+
try:
|
|
349
|
+
n = f"{len(models(name))} served"
|
|
350
|
+
except Exception as exc: # noqa: BLE001
|
|
351
|
+
n = f"unreachable ({type(exc).__name__})"
|
|
352
|
+
mark = " (default)" if name == default() else ""
|
|
353
|
+
print(f" {name}{mark}: {entry['base']} key {'set' if key(name) else 'MISSING'} {n}")
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _selftest() -> None:
|
|
357
|
+
"""No endpoint means a refusal where one is needed, not someone's LAN; the
|
|
358
|
+
yaml parser keeps headers."""
|
|
359
|
+
import tempfile
|
|
360
|
+
global FILE, YAML, LLM_DIR
|
|
361
|
+
real = FILE, YAML, LLM_DIR
|
|
362
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
363
|
+
FILE, YAML = pathlib.Path(tmp) / "endpoints.json", pathlib.Path(tmp) / "models.yaml"
|
|
364
|
+
LLM_DIR = pathlib.Path(tmp) / "llm" # absent: set_key makes it
|
|
365
|
+
try:
|
|
366
|
+
set_key("or-2", "stored")
|
|
367
|
+
os.environ[ENV_KEY + "OR_2"], os.environ[ENV_KEY + "NEW"] = "from-env", "env-only"
|
|
368
|
+
assert keys()["or-2"] == "from-env" and keys()["new"] == "env-only"
|
|
369
|
+
set_key("other", "x") # the environment's never reach the file
|
|
370
|
+
assert _stored() == {"or-2": "stored", "other": "x"}, _stored()
|
|
371
|
+
finally:
|
|
372
|
+
os.environ.pop(ENV_KEY + "OR_2", None), os.environ.pop(ENV_KEY + "NEW", None)
|
|
373
|
+
try:
|
|
374
|
+
for content in (None, "{}", "not json", "[]"):
|
|
375
|
+
if content is not None:
|
|
376
|
+
FILE.write_text(content, encoding="utf-8")
|
|
377
|
+
fresh = content in (None, "{}") # a fresh install: nothing yet, not damage
|
|
378
|
+
try:
|
|
379
|
+
assert load() == {} and fresh, f"load() accepted {content!r}"
|
|
380
|
+
except RuntimeError:
|
|
381
|
+
assert not fresh, f"load() refused {content!r}"
|
|
382
|
+
try:
|
|
383
|
+
default()
|
|
384
|
+
raise AssertionError(f"default() accepted {content!r}")
|
|
385
|
+
except RuntimeError:
|
|
386
|
+
pass
|
|
387
|
+
FILE.unlink()
|
|
388
|
+
assert registry() == [], "no yaml, no endpoints: nothing registered"
|
|
389
|
+
FILE.write_text('{"or": {"base": "https://x.example/v1/", "key": "or"}}', encoding="utf-8")
|
|
390
|
+
YAML.write_text(yaml_block("m1", "v/m:free", "or").replace(
|
|
391
|
+
" api_key_name: or", ' api_key_name: or\n headers:\n X-Title: "t"'), encoding="utf-8")
|
|
392
|
+
r = registrations()["m1"]
|
|
393
|
+
assert (r["model_name"], r["api_key_name"], r["headers"]) == ("v/m:free", "or", {"X-Title": "t"}), r
|
|
394
|
+
assert registry() == [{"alias": "m1", "served": "v/m:free", "endpoint": "or"}], registry()
|
|
395
|
+
assert entry_of("m1")["key"] == "or" and entry_of("nobody") == {}
|
|
396
|
+
# One provider, two endpoints on one base: each model stays with its own.
|
|
397
|
+
FILE.write_text('{"or": {"base": "https://x.example/v1/", "key": "or"},'
|
|
398
|
+
' "or-2": {"base": "https://x.example/v1", "key": "or-2"}}', encoding="utf-8")
|
|
399
|
+
YAML.write_text(yaml_block("m1", "a", "or") + yaml_block("m2", "a", "or-2"), encoding="utf-8")
|
|
400
|
+
assert {r["alias"]: r["endpoint"] for r in registry()} == {"m1": "or", "m2": "or-2"}, registry()
|
|
401
|
+
assert entry_of("m2")["key"] == "or-2"
|
|
402
|
+
# Kind: what an entry says, else a hub at a bare /api root (Open WebUI's), else a provider.
|
|
403
|
+
FILE.write_text(json.dumps({
|
|
404
|
+
"owui": {"base": "http://hub.example:3000/api/", "key": "a"},
|
|
405
|
+
"or": {"base": "https://x.example/api/v1", "key": "b", "default": True},
|
|
406
|
+
"lite": {"base": "http://lite.example/v1", "key": "c", "kind": "hub", "hub": "litellm"},
|
|
407
|
+
"pulled": {"base": "http://localhost:11434/v1", "key": "d"},
|
|
408
|
+
"said": {"base": "http://said.example/api", "key": "e", "kind": "aggregator"}}), encoding="utf-8")
|
|
409
|
+
got = [(kind(n), hub(n)) for n in load()]
|
|
410
|
+
assert got == [("hub", "openwebui"), ("provider", ""), ("hub", "litellm"), ("local", ""),
|
|
411
|
+
("aggregator", "")], got
|
|
412
|
+
assert find_hub("openwebui") == "owui" and find_hub("cliproxy") is None
|
|
413
|
+
# A hub's admin key: <key>-admin, else its own key unless the hub has none to fall back on.
|
|
414
|
+
set_key("c", "api")
|
|
415
|
+
assert admin_key("lite") == "api" and admin_key("lite", fallback=False) == ""
|
|
416
|
+
set_key("c-admin", "master")
|
|
417
|
+
assert admin_key("lite") == "master" == admin_key("lite", fallback=False)
|
|
418
|
+
assert version_note("litellm", "v1.106.3", ("1.106",)) == ""
|
|
419
|
+
assert "1.107.0" in version_note("litellm", "1.107.0", ("1.106",)) and version_note("litellm", "", ("1",))
|
|
420
|
+
assert not recorded_whole("or") and not recorded_whole("pulled") # an aggregator default; not the default
|
|
421
|
+
FILE.write_text(json.dumps({**load(), "or": {"base": "https://x.example/api/v1", "key": "b"},
|
|
422
|
+
"pulled": {"base": "http://localhost:11434/v1", "default": True}}),
|
|
423
|
+
encoding="utf-8")
|
|
424
|
+
assert recorded_whole("pulled") and not recorded_whole("owui")
|
|
425
|
+
# A list as an object or a bare list (GitHub's catalog); hidden ones left out.
|
|
426
|
+
listing = pathlib.Path(tmp) / "models.json"
|
|
427
|
+
for body in ('{"data": [{"id": "a"}, {"id": "b", "visibility": "hide"}]}', '[{"id": "a"}]'):
|
|
428
|
+
listing.write_text(body, encoding="utf-8")
|
|
429
|
+
assert list(list_models({"base": "x", "models_url": listing.as_uri()})) == ["a"], body
|
|
430
|
+
finally:
|
|
431
|
+
FILE, YAML, LLM_DIR = real
|
|
432
|
+
print("ok")
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
if __name__ == "__main__":
|
|
436
|
+
_selftest() if "--selftest" in sys.argv else main()
|