@wendellresearch-ai/benchwarmers 0.5.6 → 0.6.0
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/.github/logo-dark.svg +49 -0
- package/.github/logo.svg +49 -0
- package/BENCHMARKS.md +160 -0
- package/GATEWAY.md +33 -11
- package/PRIVACY.md +20 -6
- package/README.md +46 -11
- package/SECURITY.md +20 -10
- package/USAGE.md +71 -9
- package/VERSION +1 -1
- package/gateway.py +93 -82
- package/metrics/bench_collections.py +27 -3
- package/metrics/catalog.py +52 -4
- package/metrics/client.py +51 -1
- package/metrics/connect.py +225 -0
- package/metrics/decide.py +690 -27
- package/metrics/endpoints.py +4 -6
- package/metrics/guard.py +6 -4
- package/metrics/ledger.py +3 -1
- package/metrics/locked.py +102 -1
- package/metrics/offload.py +36 -19
- package/metrics/route.py +9 -1
- package/metrics/scheduler.py +11 -1
- package/metrics/test_decide.py +275 -7
- package/metrics/wrong.py +21 -9
- package/package.json +3 -1
- package/scripts/auto_retire.py +96 -21
- package/scripts/onboard.py +44 -3
- package/scripts/test_connect.py +152 -3
- package/scripts/test_first_run.py +4 -2
- package/scripts/test_formats.py +39 -0
- package/scripts/test_serve_edit.py +308 -4
- package/serve.py +1672 -141
- package/suite/README.md +5 -2
- package/suite/run.py +226 -6
- package/suite/test_pick.py +15 -0
- package/suite/test_score.py +82 -1
- package/ui/bench.css +44 -0
- package/ui/bench.js +910 -203
package/metrics/connect.py
CHANGED
|
@@ -60,6 +60,20 @@ ADDING A PROVIDER
|
|
|
60
60
|
3. Run `python metrics/connect.py --selftest`: it checks every entry's shape,
|
|
61
61
|
and every flow against a fake provider.
|
|
62
62
|
4. Add an endpoint on the Endpoints page, pick the provider and sign in once for real.
|
|
63
|
+
|
|
64
|
+
FOUND ON THIS MACHINE
|
|
65
|
+
---------------------
|
|
66
|
+
`detect()` looks for model servers running here (LOCAL: Ollama, LM Studio,
|
|
67
|
+
Jan, llama.cpp, LocalAI, vLLM, LiteLLM, CLI Proxy API, OmniRoute), so the
|
|
68
|
+
Endpoints page can offer each one with its address and kind filled in. It
|
|
69
|
+
asks their usual ports, and any port whose listening process is one of them
|
|
70
|
+
by name or command line (`listeners()`), since a port can be changed.
|
|
71
|
+
Loopback only, GET only, no key sent, every port at once. A port alone is not
|
|
72
|
+
proof: each answers /v1/models (or refuses it without a key), and where
|
|
73
|
+
something else could sit on its port, a `sign` from its docs or source says
|
|
74
|
+
which it is (llama.cpp and LocalAI share 8080); without one, the process
|
|
75
|
+
does. Adding one is the ordinary Add form, test before save and all; Ollama
|
|
76
|
+
at its usual port keeps its own flow.
|
|
63
77
|
"""
|
|
64
78
|
|
|
65
79
|
from __future__ import annotations
|
|
@@ -68,6 +82,7 @@ import base64
|
|
|
68
82
|
import datetime as dt
|
|
69
83
|
import hashlib
|
|
70
84
|
import json
|
|
85
|
+
import os
|
|
71
86
|
import pathlib
|
|
72
87
|
import re
|
|
73
88
|
import secrets
|
|
@@ -165,6 +180,211 @@ PROVIDERS: dict[str, dict] = {
|
|
|
165
180
|
}
|
|
166
181
|
|
|
167
182
|
|
|
183
|
+
def _rows(data) -> list:
|
|
184
|
+
"""An OpenAI /models answer's entries, [] for anything else."""
|
|
185
|
+
rows = data.get("data") if isinstance(data, dict) else None
|
|
186
|
+
return [r for r in rows if isinstance(r, dict)] if isinstance(rows, list) else []
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _version(status: int, data) -> bool:
|
|
190
|
+
"""{"version": ...}: Ollama's /api/version, vLLM's and LocalAI's /version."""
|
|
191
|
+
return status == 200 and isinstance(data, dict) and "version" in data
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# Model servers detect() looks for: their usual ports; `sign`, a key-free GET
|
|
195
|
+
# and a test of its (status, answer) where the port alone could be another
|
|
196
|
+
# server, none where the port is its own; the endpoint each becomes; where that
|
|
197
|
+
# is said, read 2026-11-08. llama.cpp moved its default from 8080 to 9931
|
|
198
|
+
# (README, --port), so both are asked. Order matters on a shared port: the
|
|
199
|
+
# first whose sign holds. `procs`, how its process's name starts (".exe" and
|
|
200
|
+
# case aside), and `cmd`, a word in its command line for one run by Python or
|
|
201
|
+
# Node: from release binaries, installers and launch code, read 2026-11-09.
|
|
202
|
+
LOCAL: dict[str, dict] = {
|
|
203
|
+
"ollama": {"label": "Ollama", "ports": (11434,), "kind": "local", "provider": "ollama",
|
|
204
|
+
"sign": ("/api/version", _version), "procs": ("ollama",),
|
|
205
|
+
"docs": "https://github.com/ollama/ollama/blob/main/docs/api.md#version"},
|
|
206
|
+
# The app (Scoop's manifest, LM Studio.exe) or its headless daemon (lmstudio.ai/install.sh, llmster).
|
|
207
|
+
"lmstudio": {"label": "LM Studio", "ports": (1234,), "kind": "local", "procs": ("lm studio", "llmster"),
|
|
208
|
+
# Its own REST API: {"models": [{"key", "capabilities", ...}]}, or a refusal when it wants a token.
|
|
209
|
+
"sign": ("/api/v1/models", lambda s, d: s in (401, 403) or (
|
|
210
|
+
s == 200 and isinstance(d, dict) and isinstance(d.get("models"), list))),
|
|
211
|
+
"docs": "https://lmstudio.ai/docs/developer/rest/list"},
|
|
212
|
+
# The server runs in the app itself: Jan, now Jan-Desktop (src-tauri/tauri.conf.json).
|
|
213
|
+
"jan": {"label": "Jan", "ports": (1337,), "kind": "local", "procs": ("jan",),
|
|
214
|
+
"docs": "https://docs.jan.ai/desktop/api-server"},
|
|
215
|
+
"llamacpp": {"label": "llama.cpp server", "ports": (9931, 8080), "kind": "local", "procs": ("llama-server",),
|
|
216
|
+
# Its /v1/models is public even with --api-key, each model owned_by "llamacpp".
|
|
217
|
+
"sign": ("/v1/models", lambda s, d: any(r.get("owned_by") == "llamacpp" for r in _rows(d))),
|
|
218
|
+
"docs": "https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md"},
|
|
219
|
+
"localai": {"label": "LocalAI", "ports": (8080,), "kind": "local",
|
|
220
|
+
# No owned_by in its /v1/models (endpoints/openai/list.go); /version has no middleware.
|
|
221
|
+
"sign": ("/version", _version), "procs": ("local-ai",), # also local-ai-v4.12.1-linux-amd64
|
|
222
|
+
"docs": "https://github.com/mudler/LocalAI/blob/master/core/http/routes/localai.go"},
|
|
223
|
+
"vllm": {"label": "vLLM", "ports": (8000,), "kind": "local", "cmd": ("vllm",),
|
|
224
|
+
"sign": ("/version", _version), # --api-key guards /v1 and a few others, not this
|
|
225
|
+
"docs": "https://docs.vllm.ai/en/latest/cli/serve.html"},
|
|
226
|
+
"litellm": {"label": "LiteLLM", "ports": (4000,), "kind": "hub", "hub": "litellm", "cmd": ("litellm",),
|
|
227
|
+
"sign": ("/health/liveliness", lambda s, d: s == 200 and "alive" in str(d)),
|
|
228
|
+
"docs": "https://docs.litellm.ai/docs/proxy/health"},
|
|
229
|
+
# Its releases, Homebrew's formula, and its Docker image (CLIProxyAPI).
|
|
230
|
+
"cliproxy": {"label": "CLI Proxy API", "ports": (8317,), "kind": "hub", "hub": "cliproxy",
|
|
231
|
+
"sign": ("/", lambda s, d: isinstance(d, dict) and d.get("message") == "CLI Proxy API Server"),
|
|
232
|
+
"procs": ("cli-proxy-api", "cliproxyapi"),
|
|
233
|
+
"docs": "https://github.com/router-for-me/CLIProxyAPI/blob/main/internal/api/server_routes.go"},
|
|
234
|
+
# Node running node_modules/omniroute/dist/server.js, or 9router/app/custom-server.js.
|
|
235
|
+
"omniroute": {"label": "OmniRoute / 9router", "ports": (20128,), "kind": "aggregator",
|
|
236
|
+
"cmd": ("omniroute", "9router"), "docs": "https://github.com/decolua/9router"},
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def look(url: str, timeout: float = 0.5, body: dict | None = None, headers: dict | None = None) -> tuple[int, object]:
|
|
241
|
+
"""(status, the answer as JSON or text); (0, None) when nothing usable
|
|
242
|
+
answers. A GET, or a POST of `body` as JSON. Never through a proxy or a
|
|
243
|
+
redirect: it reaches `url`'s own host or nothing, a key in `headers` too."""
|
|
244
|
+
import client # its no-redirect handler
|
|
245
|
+
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), client._NoRedirect)
|
|
246
|
+
sent = {**UA, **({"Content-Type": "application/json"} if body else {}), **(headers or {})}
|
|
247
|
+
try:
|
|
248
|
+
try:
|
|
249
|
+
with opener.open(urllib.request.Request(url, json.dumps(body).encode() if body else None, sent),
|
|
250
|
+
timeout=timeout) as r:
|
|
251
|
+
status, raw = r.status, r.read(1 << 20)
|
|
252
|
+
except urllib.error.HTTPError as e:
|
|
253
|
+
status, raw = e.code, e.read(1 << 16)
|
|
254
|
+
except Exception: # noqa: BLE001 -- refused, timed out, or not HTTP at all
|
|
255
|
+
return 0, None
|
|
256
|
+
try:
|
|
257
|
+
return status, json.loads(raw)
|
|
258
|
+
except ValueError:
|
|
259
|
+
return status, raw.decode("utf-8", "replace")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def listeners() -> list[tuple[int, int, str, str]]:
|
|
263
|
+
"""(pid, port, process name, command line) of each TCP listener on loopback
|
|
264
|
+
or all addresses, from the OS's own tools: netstat and PowerShell's process
|
|
265
|
+
list on Windows, ss on Linux, lsof on macOS, ps for a command line there.
|
|
266
|
+
[] when a tool is missing or fails (a container, no rights to see), which
|
|
267
|
+
leaves detect() the usual ports."""
|
|
268
|
+
import subprocess
|
|
269
|
+
|
|
270
|
+
def run(*cmd: str) -> str:
|
|
271
|
+
return subprocess.run(cmd, capture_output=True, text=True, errors="replace", timeout=5,
|
|
272
|
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)).stdout
|
|
273
|
+
try:
|
|
274
|
+
if sys.platform == "win32":
|
|
275
|
+
# A listener's far end is port 0; its state's name is translated, so not asked.
|
|
276
|
+
socks = [(int(p[4]), p[1]) for p in map(str.split, run("netstat", "-ano").splitlines())
|
|
277
|
+
if len(p) == 5 and p[0] == "TCP" and p[2].endswith(":0")]
|
|
278
|
+
# Names and command lines in one look, sooner than tasklist gives names alone.
|
|
279
|
+
info = {p["ProcessId"]: (p["Name"] or "", p["CommandLine"] or "") for p in json.loads(run(
|
|
280
|
+
"powershell", "-NoProfile", "-NonInteractive", "-Command",
|
|
281
|
+
"Get-CimInstance Win32_Process | Select ProcessId,Name,CommandLine | ConvertTo-Json -Compress"))}
|
|
282
|
+
else: # (pid, address, name), then each one's command line
|
|
283
|
+
if sys.platform == "darwin":
|
|
284
|
+
found, pid, name = [], 0, ""
|
|
285
|
+
for line in run("lsof", "-nP", "-iTCP", "-sTCP:LISTEN", "-Fpcn", "+c", "0").splitlines():
|
|
286
|
+
if line[:1] == "p":
|
|
287
|
+
pid = int(line[1:])
|
|
288
|
+
elif line[:1] == "c":
|
|
289
|
+
name = line[1:]
|
|
290
|
+
elif line[:1] == "n":
|
|
291
|
+
found.append((pid, line[1:], name))
|
|
292
|
+
else: # ... users:(("ollama",pid=812,fd=3))
|
|
293
|
+
found = [(int(pid), p[3], name) for p in map(str.split, run("ss", "-ltnp").splitlines())
|
|
294
|
+
if p[:1] == ["LISTEN"] for name, pid in re.findall(r'"([^"]*)",pid=(\d+)', " ".join(p[5:]))]
|
|
295
|
+
pids = ",".join({str(f[0]) for f in found})
|
|
296
|
+
args = dict(line.strip().partition(" ")[::2] for line in run("ps", "-o", "pid=,args=", "-p", pids).splitlines())
|
|
297
|
+
socks, info = [f[:2] for f in found], {f[0]: (f[2], args.get(str(f[0]), "")) for f in found}
|
|
298
|
+
except Exception: # noqa: BLE001 -- missing, refused, timed out, or not what was expected
|
|
299
|
+
return []
|
|
300
|
+
rows = []
|
|
301
|
+
for pid, addr in socks:
|
|
302
|
+
host, _, port = addr.rpartition(":")
|
|
303
|
+
if host.strip("[]") in ("0.0.0.0", "127.0.0.1", "::", "::1", "*") and port.isdigit():
|
|
304
|
+
rows.append((pid, int(port), *info.get(pid, ("", ""))))
|
|
305
|
+
return rows
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def detect(skip=(), timeout: float = 0.5) -> list[dict]:
|
|
309
|
+
"""The LOCAL servers answering on this machine, but none on a port in
|
|
310
|
+
`skip` (this install's own) or already an endpoint. Each: id, label, port,
|
|
311
|
+
base, kind, hub, provider (Ollama's Connect flow), models (None when the
|
|
312
|
+
list needs a key) and key. Asked: each one's usual ports, and the ports of
|
|
313
|
+
processes that are one of them (`procs`, `cmd`), or Ollama's own
|
|
314
|
+
OLLAMA_HOST; a port no such process holds is asked only if it is usual.
|
|
315
|
+
Every port at once, so a look takes the listing (about a second) and
|
|
316
|
+
`timeout`, a server that hangs included. Loopback only, so a GET here
|
|
317
|
+
never leaves this machine."""
|
|
318
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
319
|
+
taken = set(skip) | _endpoint_ports()
|
|
320
|
+
by_port: dict[int, list[str]] = {}
|
|
321
|
+
host = re.search(r":(\d+)/?$", os.environ.get("OLLAMA_HOST", "").strip()) # as 0.0.0.0:11435
|
|
322
|
+
# ponytail: the listing (about 0.8 s on Windows), then the probes; overlap them if a look must be quicker
|
|
323
|
+
running = listeners() + ([(0, int(host[1]), "ollama", "")] if host else [])
|
|
324
|
+
matched = [(port, sid) for _, port, name, cmd in running for sid, s in LOCAL.items()
|
|
325
|
+
if name.lower().startswith(s.get("procs", ())) or any(c in cmd.lower() for c in s.get("cmd", ()))]
|
|
326
|
+
for port, sid in matched + [(port, sid) for sid, s in LOCAL.items() for port in s["ports"]]:
|
|
327
|
+
if port not in taken and sid not in by_port.setdefault(port, []):
|
|
328
|
+
by_port[port].append(sid)
|
|
329
|
+
|
|
330
|
+
def identify(port: int, ids: list[str]) -> dict | None:
|
|
331
|
+
got = {"/v1/models": look(f"http://127.0.0.1:{port}/v1/models", timeout)}
|
|
332
|
+
status, data = got["/v1/models"]
|
|
333
|
+
keyed = status in (401, 403)
|
|
334
|
+
if not keyed and not (status == 200 and isinstance(data, dict) and isinstance(data.get("data"), list)):
|
|
335
|
+
return None # not an OpenAI-compatible API, or nothing there
|
|
336
|
+
for sid in ids:
|
|
337
|
+
s = LOCAL[sid]
|
|
338
|
+
path, test = s.get("sign") or ("/v1/models", None)
|
|
339
|
+
if path not in got:
|
|
340
|
+
got[path] = look(f"http://127.0.0.1:{port}{path}", timeout)
|
|
341
|
+
if test is None or test(*got[path]):
|
|
342
|
+
own = s.get("provider", "") if port in s["ports"] else "" # Ollama's flow knows only its usual port
|
|
343
|
+
return {"id": sid, "label": s["label"], "port": port, "kind": s["kind"], "hub": s.get("hub", ""),
|
|
344
|
+
"provider": own, "docs": s["docs"],
|
|
345
|
+
"base": PROVIDERS[own]["base"] if own else f"http://127.0.0.1:{port}/v1",
|
|
346
|
+
"models": None if keyed else len(_rows(data)), "key": keyed}
|
|
347
|
+
return None
|
|
348
|
+
|
|
349
|
+
if not by_port:
|
|
350
|
+
return []
|
|
351
|
+
with ThreadPoolExecutor(len(by_port)) as pool:
|
|
352
|
+
return [f for f in pool.map(lambda kv: identify(*kv), by_port.items()) if f]
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _endpoint_ports() -> set[int]:
|
|
356
|
+
"""The ports of endpoints on this machine, under any of its names or
|
|
357
|
+
addresses: an endpoint added by a LAN address is the same server. No DNS:
|
|
358
|
+
resolving this machine's own name took seconds on some (macOS CI); an
|
|
359
|
+
address binds only if it is this machine's."""
|
|
360
|
+
import ipaddress
|
|
361
|
+
import socket
|
|
362
|
+
name = socket.gethostname().lower()
|
|
363
|
+
|
|
364
|
+
def here(host: str | None) -> bool:
|
|
365
|
+
if host in ("localhost", "127.0.0.1", "::1", name):
|
|
366
|
+
return True
|
|
367
|
+
try:
|
|
368
|
+
ip = ipaddress.ip_address(host or "")
|
|
369
|
+
except ValueError:
|
|
370
|
+
return False # another name: not resolved here
|
|
371
|
+
try:
|
|
372
|
+
with socket.socket(socket.AF_INET6 if ip.version == 6 else socket.AF_INET) as s:
|
|
373
|
+
s.bind((str(ip), 0))
|
|
374
|
+
return True
|
|
375
|
+
except OSError:
|
|
376
|
+
return False
|
|
377
|
+
ports = set()
|
|
378
|
+
for e in endpoints.load().values():
|
|
379
|
+
url = urllib.parse.urlsplit(str(e.get("base", "")))
|
|
380
|
+
try:
|
|
381
|
+
if here(url.hostname):
|
|
382
|
+
ports.add(url.port or (443 if url.scheme == "https" else 80))
|
|
383
|
+
except ValueError: # a port that is not a number: not one of these
|
|
384
|
+
pass
|
|
385
|
+
return ports
|
|
386
|
+
|
|
387
|
+
|
|
168
388
|
class ConnectError(Exception):
|
|
169
389
|
"""A sign-in that cannot go on; the text is for the person signing in."""
|
|
170
390
|
|
|
@@ -515,6 +735,11 @@ def check() -> list[str]:
|
|
|
515
735
|
problems.append(f"{pid}: {k} must be an object")
|
|
516
736
|
if (p.get("entry") or {}).get("kind") not in set(endpoints.KINDS) - {"hub"}:
|
|
517
737
|
problems.append(f"{pid}: entry.kind is provider, aggregator or local (a hub is added by hand)")
|
|
738
|
+
for sid, s in LOCAL.items(): # the id is the name an endpoint is offered under
|
|
739
|
+
if not endpoints.NAME.match(sid) or not s.get("docs", "").startswith("https://"):
|
|
740
|
+
problems.append(f"local {sid}: the id must match endpoints.NAME, and docs be https")
|
|
741
|
+
if s.get("kind") not in endpoints.KINDS or (s["kind"] == "hub") != (s.get("hub") in endpoints.HUBS):
|
|
742
|
+
problems.append(f"local {sid}: kind is one of endpoints.KINDS, and a hub names one of endpoints.HUBS")
|
|
518
743
|
return problems
|
|
519
744
|
|
|
520
745
|
|