@wendellresearch-ai/benchwarmers 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/AGENTS.md +152 -147
  2. package/BENCHMARKS.md +262 -262
  3. package/GATEWAY.md +415 -272
  4. package/NOTICE +28 -28
  5. package/PRIVACY.md +46 -46
  6. package/README.md +408 -314
  7. package/SECURITY.md +83 -83
  8. package/TERMS.md +22 -22
  9. package/USAGE.md +186 -163
  10. package/VERSION +1 -1
  11. package/bin/benchwarmers.js +16 -74
  12. package/cli.py +674 -0
  13. package/examples/config/gateway.json +5 -5
  14. package/examples/config/model-roles.json +11 -11
  15. package/gateway.py +939 -755
  16. package/launcher.py +120 -0
  17. package/mcp_server.py +266 -266
  18. package/metrics/alerts.py +116 -116
  19. package/metrics/aliases.py +41 -41
  20. package/metrics/bench_collections.py +141 -141
  21. package/metrics/catalog.py +304 -295
  22. package/metrics/client.py +804 -394
  23. package/metrics/cliproxy.py +191 -191
  24. package/metrics/connect.py +691 -691
  25. package/metrics/cost.py +171 -152
  26. package/metrics/decide.py +227 -227
  27. package/metrics/discover.py +318 -318
  28. package/metrics/endpoints.py +436 -436
  29. package/metrics/formats.py +1021 -0
  30. package/metrics/guard.py +647 -592
  31. package/metrics/learn.py +234 -221
  32. package/metrics/ledger.py +148 -0
  33. package/metrics/litellm_hub.py +206 -206
  34. package/metrics/model_roles.py +94 -84
  35. package/metrics/offload.py +166 -166
  36. package/metrics/owui.py +205 -205
  37. package/metrics/pacing.py +248 -0
  38. package/metrics/paths.py +60 -60
  39. package/metrics/quota.py +383 -0
  40. package/metrics/route.py +581 -579
  41. package/metrics/scheduler.py +275 -275
  42. package/metrics/sources.py +167 -167
  43. package/metrics/wrong.py +144 -144
  44. package/package.json +69 -65
  45. package/perf/README.md +89 -89
  46. package/perf/run.py +284 -261
  47. package/scripts/auto_retire.py +367 -367
  48. package/scripts/check.py +113 -106
  49. package/scripts/check_deprecations.py +378 -378
  50. package/scripts/check_themes.py +103 -103
  51. package/scripts/init.py +124 -124
  52. package/scripts/list_served.py +78 -78
  53. package/scripts/notice.py +78 -78
  54. package/scripts/onboard.py +410 -331
  55. package/scripts/provider_tags.py +129 -129
  56. package/scripts/reprefix.py +136 -136
  57. package/scripts/sync_config.py +105 -105
  58. package/scripts/test_auth.py +255 -255
  59. package/scripts/test_cli.py +189 -0
  60. package/scripts/test_client.py +277 -233
  61. package/scripts/test_connect.py +222 -222
  62. package/scripts/test_first_run.py +180 -180
  63. package/scripts/test_formats.py +549 -0
  64. package/scripts/test_hubs.py +328 -328
  65. package/scripts/test_launcher.py +67 -0
  66. package/scripts/test_llm_log.py +85 -81
  67. package/scripts/test_mcp.py +178 -178
  68. package/scripts/test_merged.py +215 -214
  69. package/scripts/test_no_owui.py +291 -291
  70. package/scripts/test_owui.py +90 -90
  71. package/scripts/test_quota.py +334 -0
  72. package/scripts/test_serve_edit.py +453 -438
  73. package/scripts/test_server_json.py +79 -0
  74. package/scripts/test_verdict.py +102 -98
  75. package/scripts/test_vision.py +231 -228
  76. package/serve.py +3972 -3764
  77. package/suite/mine.py +154 -154
  78. package/suite/pace.py +145 -145
  79. package/suite/run.py +1046 -1042
  80. package/suite/seed.py +421 -421
  81. package/suite/tasks/claim-alias-unbound/fixtures/onboard.py +286 -286
  82. package/suite/tasks/claim-error-text-string/fixtures/decide.py +187 -187
  83. package/suite/tasks/claim-list-body/fixtures/gateway.py +235 -235
  84. package/suite/tasks/claim-member-nominated-twice/fixtures/gateway.py +284 -284
  85. package/suite/tasks/claim-nominee-uses-slot/fixtures/gateway.py +284 -284
  86. package/suite/tasks/claim-silent-exit/fixtures/gateway.py +235 -235
  87. package/suite/tasks/claim-sse-hang/fixtures/gateway.py +235 -235
  88. package/suite/tasks/claim-unknown-nominee-crash/fixtures/gateway.py +284 -284
  89. package/suite/tasks/rebench-matchms-809-bug/task.json +23 -23
  90. package/suite/tasks/rebench-matchms-809-control/task.json +27 -27
  91. package/suite/tasks/rebench-more-itertools-1028-bug/task.json +23 -23
  92. package/suite/tasks/rebench-more-itertools-1028-control/task.json +27 -27
  93. package/suite/tasks/rebench-narwhals-2864-bug/task.json +23 -23
  94. package/suite/tasks/rebench-narwhals-2864-control/task.json +27 -27
  95. package/suite/tasks/rebench-quantecon-py-769-bug/task.json +23 -23
  96. package/suite/tasks/rebench-quantecon-py-769-control/task.json +27 -27
  97. package/suite/tasks/rebench-scrapy-6867-bug/task.json +23 -23
  98. package/suite/tasks/rebench-scrapy-6867-control/task.json +27 -27
  99. package/ui/bench.css +430 -425
  100. package/ui/bench.js +1757 -1576
  101. package/ui/themes/dark.css +35 -35
  102. package/ui/themes/hardwood.css +52 -52
  103. package/ui/themes/rainforest.css +32 -32
@@ -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()