@wendellresearch-ai/benchwarmers 0.5.5 → 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/AGENTS.md +4 -1
- package/BENCHMARKS.md +160 -0
- package/GATEWAY.md +59 -12
- package/PRIVACY.md +25 -6
- package/README.md +62 -15
- package/SECURITY.md +20 -9
- package/USAGE.md +87 -14
- package/VERSION +1 -1
- package/cli.py +12 -1
- package/gateway.py +122 -91
- package/mcp_server.py +2 -1
- package/metrics/alerts.py +25 -0
- package/metrics/bench_collections.py +58 -9
- package/metrics/catalog.py +54 -9
- package/metrics/client.py +51 -1
- package/metrics/connect.py +225 -0
- package/metrics/decide.py +690 -27
- package/metrics/discover.py +191 -11
- 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/owui.py +26 -0
- package/metrics/route.py +19 -4
- package/metrics/scheduler.py +11 -1
- package/metrics/sources.py +15 -2
- 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 +100 -1
- package/scripts/test_serve_edit.py +327 -4
- package/serve.py +1746 -170
- 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 +940 -207
package/scripts/auto_retire.py
CHANGED
|
@@ -49,8 +49,10 @@ from __future__ import annotations
|
|
|
49
49
|
import csv
|
|
50
50
|
import datetime as dt
|
|
51
51
|
import json
|
|
52
|
+
import os
|
|
52
53
|
import pathlib
|
|
53
54
|
import sys
|
|
55
|
+
import threading
|
|
54
56
|
|
|
55
57
|
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
56
58
|
sys.path.insert(0, str(ROOT / "metrics"))
|
|
@@ -201,11 +203,23 @@ def _current(alias: str, history: dict[str, str]) -> str:
|
|
|
201
203
|
return alias
|
|
202
204
|
|
|
203
205
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
206
|
+
# evidence() runs on every dashboard render, and re-read 13 MB of offloads and
|
|
207
|
+
# 1,900 saved answers each time: 1.2 s on the owner's data. Each part is read
|
|
208
|
+
# again only when its file changes.
|
|
209
|
+
_perf: tuple = (None, []) # ((alias history, history.csv's stat), its rows)
|
|
210
|
+
_outputs: dict[str, tuple] = {} # saved answer's path -> ((mtime, size), day it answered or None)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _stat(path: pathlib.Path) -> tuple:
|
|
214
|
+
try:
|
|
215
|
+
st = path.stat()
|
|
216
|
+
except OSError:
|
|
217
|
+
return (str(path),)
|
|
218
|
+
return str(path), st.st_mtime_ns, st.st_size
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _perf_rows(path: pathlib.Path, history: dict[str, str]) -> list[dict]:
|
|
207
222
|
rows = []
|
|
208
|
-
path = DATA / "perf" / "history.csv"
|
|
209
223
|
if path.exists():
|
|
210
224
|
with path.open(encoding="utf-8", newline="") as fh:
|
|
211
225
|
for r in csv.DictReader(fh):
|
|
@@ -215,28 +229,74 @@ def evidence() -> tuple[list[dict], dict[str, dt.date], dict[str, dt.date]]:
|
|
|
215
229
|
"failure": r.get("failure") or "",
|
|
216
230
|
"detail": r.get("detail") or ""})
|
|
217
231
|
rows.sort(key=lambda r: r["date"])
|
|
232
|
+
return rows
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
_useful_lock = threading.Lock()
|
|
236
|
+
_useful_seen: dict = {"mark": {}, "history": None, "days": {}}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _useful(path: pathlib.Path, history: dict[str, str]) -> dict[str, dt.date]:
|
|
240
|
+
"""alias -> the last day a useful offload says it answered. Only the lines
|
|
241
|
+
added since the last call are read (the gateway adds some on every call),
|
|
242
|
+
all of them again after a rename."""
|
|
243
|
+
with _useful_lock:
|
|
244
|
+
s = _useful_seen
|
|
245
|
+
if s["history"] != history:
|
|
246
|
+
s.update(mark={}, history=history)
|
|
247
|
+
fresh, lines = locked.appended(path, s["mark"])
|
|
248
|
+
if fresh:
|
|
249
|
+
s["days"] = {}
|
|
250
|
+
days = s["days"]
|
|
251
|
+
for line in lines:
|
|
252
|
+
try:
|
|
253
|
+
o = json.loads(line)
|
|
254
|
+
except ValueError:
|
|
255
|
+
continue
|
|
256
|
+
if o.get("useful"):
|
|
257
|
+
alias, day = _current(o["model"], history), dt.date.fromisoformat(o["date"])
|
|
258
|
+
days[alias] = max(days.get(alias, day), day)
|
|
259
|
+
return dict(days)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _saved_answers():
|
|
263
|
+
"""(model, day) for each saved answer that was a real answer. The listing
|
|
264
|
+
brings each file's stat (free on Windows); a file is read again only when
|
|
265
|
+
it changes."""
|
|
266
|
+
from run import outcome
|
|
267
|
+
for folder in DATA.glob("suite/tasks/*/output"): # the saved answers are the install's data
|
|
268
|
+
try:
|
|
269
|
+
listing = list(os.scandir(folder))
|
|
270
|
+
except OSError:
|
|
271
|
+
continue
|
|
272
|
+
for e in listing:
|
|
273
|
+
if not e.name.endswith(".md"):
|
|
274
|
+
continue
|
|
275
|
+
st = e.stat()
|
|
276
|
+
hit = _outputs.get(e.path)
|
|
277
|
+
if hit is None or hit[0] != (st.st_mtime_ns, st.st_size):
|
|
278
|
+
text = pathlib.Path(e.path).read_text(encoding="utf-8", errors="replace")
|
|
279
|
+
hit = _outputs[e.path] = ((st.st_mtime_ns, st.st_size),
|
|
280
|
+
dt.date.fromtimestamp(st.st_mtime) if outcome(text) is None else None)
|
|
281
|
+
if hit[1]:
|
|
282
|
+
yield e.name[:-3], hit[1]
|
|
218
283
|
|
|
219
|
-
answers: dict[str, dt.date] = {}
|
|
220
284
|
|
|
221
|
-
|
|
285
|
+
def evidence() -> tuple[list[dict], dict[str, dt.date], dict[str, dt.date]]:
|
|
286
|
+
"""(perf rows, last non-perf answer per alias, restore date per alias).
|
|
287
|
+
The rows are shared between calls: read them, never change them."""
|
|
288
|
+
global _perf
|
|
289
|
+
history = locked.read_json(DATA / "config" / "alias-history.json") # {} until a rename
|
|
290
|
+
path = DATA / "perf" / "history.csv"
|
|
291
|
+
if _perf[0] != (key := (history, _stat(path))):
|
|
292
|
+
_perf = (key, _perf_rows(path, history))
|
|
293
|
+
rows = _perf[1]
|
|
294
|
+
offloads = DATA / "metrics" / "offloads.jsonl"
|
|
295
|
+
answers = _useful(offloads, history)
|
|
296
|
+
for alias, day in _saved_answers():
|
|
222
297
|
alias = _current(alias, history)
|
|
223
298
|
answers[alias] = max(answers.get(alias, day), day)
|
|
224
299
|
|
|
225
|
-
offloads = DATA / "metrics" / "offloads.jsonl"
|
|
226
|
-
for line in (offloads.read_text(encoding="utf-8").splitlines()
|
|
227
|
-
if offloads.exists() else []):
|
|
228
|
-
try:
|
|
229
|
-
o = json.loads(line)
|
|
230
|
-
except ValueError:
|
|
231
|
-
continue
|
|
232
|
-
if o.get("useful"):
|
|
233
|
-
saw(o["model"], dt.date.fromisoformat(o["date"]))
|
|
234
|
-
|
|
235
|
-
from run import outcome
|
|
236
|
-
for out in DATA.glob("suite/tasks/*/output/*.md"): # the saved answers are the install's data
|
|
237
|
-
if outcome(out.read_text(encoding="utf-8", errors="replace")) is None:
|
|
238
|
-
saw(out.stem, dt.date.fromtimestamp(out.stat().st_mtime))
|
|
239
|
-
|
|
240
300
|
restored: dict[str, dt.date] = {}
|
|
241
301
|
changes = DATA / "config" / "changes.jsonl"
|
|
242
302
|
for line in (changes.read_text(encoding="utf-8").splitlines()
|
|
@@ -403,6 +463,21 @@ def _selfcheck() -> None:
|
|
|
403
463
|
refused = [row("pk", n, "signin") for n in (9, 5, 0)]
|
|
404
464
|
p = plan(d, {"pk": "v/pk"}, {"v/pk"}, {}, refused, {}, {}, {"v/pk": "v"})
|
|
405
465
|
assert not p["retire"] and not p["confirm"] and not p["pending"], p
|
|
466
|
+
# evidence() reads a file again when it changes, and only then
|
|
467
|
+
global DATA
|
|
468
|
+
import tempfile
|
|
469
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
470
|
+
DATA = pathlib.Path(tmp)
|
|
471
|
+
out = DATA / "suite" / "tasks" / "t" / "output" / "m.md"
|
|
472
|
+
out.parent.mkdir(parents=True)
|
|
473
|
+
out.write_text("a real answer, long enough to count as one: " * 2, encoding="utf-8")
|
|
474
|
+
log = DATA / "metrics" / "offloads.jsonl"
|
|
475
|
+
log.parent.mkdir()
|
|
476
|
+
log.write_text('{"model": "o", "date": "2026-10-01", "useful": true}\n', encoding="utf-8")
|
|
477
|
+
assert set(evidence()[1]) == {"m", "o"}
|
|
478
|
+
out.write_text("<<timed out>>", encoding="utf-8")
|
|
479
|
+
log.write_text("", encoding="utf-8")
|
|
480
|
+
assert evidence()[1] == {}
|
|
406
481
|
print("auto_retire: rules hold")
|
|
407
482
|
|
|
408
483
|
|
package/scripts/onboard.py
CHANGED
|
@@ -21,7 +21,9 @@ WHAT IT DOES, AND WHY IN THIS ORDER
|
|
|
21
21
|
3. **Register and sync**, then, with an Open WebUI hub, refresh the provider
|
|
22
22
|
tags so the connection behind it is known — which is what the router uses
|
|
23
23
|
to keep a panel independent. Without one the endpoint is the connection.
|
|
24
|
-
Then refresh the catalog, so its details (context, tools, vision) show now
|
|
24
|
+
Then refresh the catalog, so its details (context, tools, vision) show now,
|
|
25
|
+
and ask a decision model which role suits it from them: kept as a
|
|
26
|
+
suggestion with its confidence (metrics/decide.py suggest_role), never set.
|
|
25
27
|
4. **Probe availability** before spending anything: two prompt sizes, two
|
|
26
28
|
samples. A model that cannot answer "ready" will not answer a 46 KB task,
|
|
27
29
|
and finding that out here costs four calls instead of six large ones.
|
|
@@ -185,6 +187,17 @@ def makes_images(alias: str, served_name: str) -> bool:
|
|
|
185
187
|
return "image" in out if out else bool(IMAGE_NAMES.search(served_name))
|
|
186
188
|
|
|
187
189
|
|
|
190
|
+
def role_facts(alias: str, served_name: str, connection: str) -> str:
|
|
191
|
+
"""What a decision model is told when asked for a new chat model's role:
|
|
192
|
+
its name, connection and catalog facts, the description last."""
|
|
193
|
+
import catalog
|
|
194
|
+
rec = catalog.info(alias)
|
|
195
|
+
facts = [f"{k.replace('_', ' ')}: {', '.join(map(str, v)) if isinstance(v, list) else v}"
|
|
196
|
+
for k in catalog.FIELDS if k != "description" and (v := rec.get(k)) not in (None, "", [])]
|
|
197
|
+
return "\n".join([f"model: {served_name}", f"connection: {connection}", *facts,
|
|
198
|
+
f"description: {rec.get('description') or 'none known'}"])
|
|
199
|
+
|
|
200
|
+
|
|
188
201
|
def run(*args: str, timeout: int = 3600) -> subprocess.CompletedProcess:
|
|
189
202
|
return subprocess.run([str(PY), *args], cwd=str(ROOT), timeout=timeout,
|
|
190
203
|
capture_output=True, text=True, encoding="utf-8",
|
|
@@ -305,7 +318,13 @@ def main() -> None:
|
|
|
305
318
|
lambda roles: roles.setdefault("models", {}).__setitem__(alias, "vision"),
|
|
306
319
|
ensure_ascii=False)
|
|
307
320
|
print(" role vision: a seat only for requests that carry images")
|
|
308
|
-
|
|
321
|
+
# The llm CLI's copy of the registry, only while it is the client: the
|
|
322
|
+
# built-in client reads the data folder's, and choosing the llm CLI on
|
|
323
|
+
# Settings syncs it whole (serve.save_client).
|
|
324
|
+
import client as model_client
|
|
325
|
+
synced = model_client.chosen()[0] == "llm"
|
|
326
|
+
if synced:
|
|
327
|
+
run("scripts/sync_config.py", timeout=300)
|
|
309
328
|
# Open WebUI's tags name the connection behind the hub; without a hub the
|
|
310
329
|
# endpoint is the connection, and route.connection_of needs no tags.
|
|
311
330
|
if endpoints.find_hub("openwebui"):
|
|
@@ -319,7 +338,8 @@ def main() -> None:
|
|
|
319
338
|
catalog = run("metrics/catalog.py", timeout=300)
|
|
320
339
|
details = ("details from the catalog" if catalog.returncode == 0
|
|
321
340
|
else f"catalog refresh failed (exit {catalog.returncode}); details at its next run")
|
|
322
|
-
print(f" [3/5] synced
|
|
341
|
+
print(f" [3/5] {'synced to the llm CLI' if synced else 'the llm CLI not synced (the built-in client is in use)'};"
|
|
342
|
+
f" connection is {connection}; {details}")
|
|
323
343
|
|
|
324
344
|
# An image generator: role image, which no chat shape admits, so it never
|
|
325
345
|
# fills a seat or a collection; a chat probe and the suite would only
|
|
@@ -341,6 +361,16 @@ def main() -> None:
|
|
|
341
361
|
" never a seat, not probed, no suite.\n")
|
|
342
362
|
return
|
|
343
363
|
|
|
364
|
+
# A decision model's suggestion for its role, from what the catalog knows:
|
|
365
|
+
# kept for the model popup (metrics/decided.json), never set from here.
|
|
366
|
+
try:
|
|
367
|
+
hint = decide.suggest_role(alias, role_facts(alias, name, connection))
|
|
368
|
+
print(f" role suggested by {hint['by']}: {hint['role']} at {hint['confidence']:.2f}"
|
|
369
|
+
" (a suggestion; the role is yours to set in its model popup)" if "role" in hint
|
|
370
|
+
else f" no role suggestion: {hint['error']}")
|
|
371
|
+
except Exception as exc: # noqa: BLE001 -- a suggestion never stops an onboarding
|
|
372
|
+
print(f" no role suggestion: {type(exc).__name__}: {exc}")
|
|
373
|
+
|
|
344
374
|
# 4. availability
|
|
345
375
|
print(" [4/5] probing availability (4 calls)...")
|
|
346
376
|
since = dt.datetime.now().isoformat(timespec="seconds")
|
|
@@ -414,6 +444,17 @@ def _selftest() -> None:
|
|
|
414
444
|
# The name fallback, for a model the catalog says nothing about.
|
|
415
445
|
assert all(IMAGE_NAMES.search(s) for s in ("gpt-image-2.5-flare", "openai/dall-e-3", "black-forest-labs/flux.2-pro"))
|
|
416
446
|
assert not any(IMAGE_NAMES.search(s) for s in ("gpt-5.2", "reflux-7b", "gemini-3-pro"))
|
|
447
|
+
# What the role suggestion is told: the catalog's facts, the description last.
|
|
448
|
+
import catalog
|
|
449
|
+
real = catalog.info
|
|
450
|
+
catalog.info = lambda a: {"context_window": 131072, "vision": False, "input_modalities": ["text"],
|
|
451
|
+
"tools": None, "description": "A coding model."}
|
|
452
|
+
try:
|
|
453
|
+
facts = role_facts("m1", "vendor/m1", "groq").splitlines()
|
|
454
|
+
finally:
|
|
455
|
+
catalog.info = real
|
|
456
|
+
assert facts == ["model: vendor/m1", "connection: groq", "context window: 131072", "vision: False",
|
|
457
|
+
"input modalities: text", "description: A coding model."], facts
|
|
417
458
|
print("ok")
|
|
418
459
|
|
|
419
460
|
|
package/scripts/test_connect.py
CHANGED
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
metrics/connect.py --selftest covers the flows themselves; this covers what
|
|
6
6
|
serve.py does with them: the callback route on the real handler, saving the
|
|
7
7
|
endpoint by save_endpoint's rules (an owner's headers kept), the refresh token
|
|
8
|
-
beside the key, a device sign-in finished by polling, Ollama's local add,
|
|
9
|
-
models on a renewing endpoint routed to the built-in client
|
|
10
|
-
|
|
8
|
+
beside the key, a device sign-in finished by polling, Ollama's local add,
|
|
9
|
+
models on a renewing endpoint routed to the built-in client, and model servers
|
|
10
|
+
found on this machine and what they say of their models for the catalog
|
|
11
|
+
(local_servers). Endpoints, keys, the change log and connect.json all point at
|
|
12
|
+
a temp folder.
|
|
11
13
|
"""
|
|
12
14
|
|
|
13
15
|
from __future__ import annotations
|
|
@@ -70,6 +72,151 @@ def serve_on(handler) -> ThreadingHTTPServer:
|
|
|
70
72
|
return server
|
|
71
73
|
|
|
72
74
|
|
|
75
|
+
SHAPES: dict[int, dict] = {} # port -> path -> (status, answer): what the server there says
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Server(BaseHTTPRequestHandler):
|
|
79
|
+
"""A local model server, answering as SHAPES says the one on its port does."""
|
|
80
|
+
def do_GET(self): # noqa: N802
|
|
81
|
+
status, body = SHAPES[self.server.server_port].get(self.path, (404, {"error": "not found"}))
|
|
82
|
+
raw = body.encode() if isinstance(body, str) else json.dumps(body).encode()
|
|
83
|
+
self.send_response(status)
|
|
84
|
+
self.send_header("Content-Type", "text/html" if isinstance(body, str) else "application/json")
|
|
85
|
+
self.send_header("Content-Length", str(len(raw)))
|
|
86
|
+
self.end_headers()
|
|
87
|
+
self.wfile.write(raw)
|
|
88
|
+
|
|
89
|
+
def do_POST(self): # noqa: N802 -- Ollama's /api/show: one answer whatever is asked
|
|
90
|
+
self.rfile.read(int(self.headers.get("Content-Length") or 0))
|
|
91
|
+
self.do_GET()
|
|
92
|
+
|
|
93
|
+
def log_message(self, *_a):
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def local_servers() -> None:
|
|
98
|
+
"""Found on this machine (connect.detect): each server told by its answers,
|
|
99
|
+
llama.cpp and LocalAI apart on one port, impostors and a hung port ignored,
|
|
100
|
+
this install's ports and servers already added skipped; then on any port,
|
|
101
|
+
by the process listening there. Fake servers on free ports stand in for the
|
|
102
|
+
real ones, a table of their own for this machine's processes (none at
|
|
103
|
+
first: the usual ports alone); endpoints.json is main()'s temp one."""
|
|
104
|
+
import os
|
|
105
|
+
import socket
|
|
106
|
+
import time
|
|
107
|
+
|
|
108
|
+
def listing(*owners):
|
|
109
|
+
return 200, {"object": "list", "data": [{"id": f"m{i}", "object": "model", **({"owned_by": o} if o else {})}
|
|
110
|
+
for i, o in enumerate(owners)]}
|
|
111
|
+
refused, version = (401, {"error": "Missing API key"}), (200, {"version": "1.0"})
|
|
112
|
+
shapes = {
|
|
113
|
+
"ollama": {"/v1/models": listing("library"), "/api/version": version, "/api/show": (200, {
|
|
114
|
+
"capabilities": ["completion", "tools"], "model_info": {
|
|
115
|
+
"general.architecture": "qwen35", "qwen35.context_length": 262144,
|
|
116
|
+
"qwen35.rope.scaling.original_context_length": 16384}})},
|
|
117
|
+
"lmstudio": {"/v1/models": listing(None, None, None), "/api/v1/models": (200, {"models": [
|
|
118
|
+
{"key": "pub/m0", "max_context_length": 32768, "variants": ["pub/m0@q4_k_m"],
|
|
119
|
+
"capabilities": {"vision": True, "trained_for_tool_use": False},
|
|
120
|
+
"loaded_instances": [{"id": "pub/m0:2", "config": {"context_length": 4096}}]},
|
|
121
|
+
{"key": "m1", "max_context_length": 8192, "loaded_instances": [],
|
|
122
|
+
"capabilities": {"vision": False, "trained_for_tool_use": True, "reasoning": {"default": "on"}}}]})},
|
|
123
|
+
"jan": {"/v1/models": refused},
|
|
124
|
+
"llamacpp": {"/v1/models": listing("llamacpp")},
|
|
125
|
+
"localai": {"/v1/models": listing(None, None), "/version": version},
|
|
126
|
+
"vllm": {"/v1/models": listing("vllm"), "/version": version},
|
|
127
|
+
"litellm": {"/v1/models": refused, "/health/liveliness": (200, "I'm alive!")},
|
|
128
|
+
"cliproxy": {"/v1/models": refused, "/": (200, {"message": "CLI Proxy API Server", "endpoints": []})},
|
|
129
|
+
"omniroute": {"/v1/models": listing("a", "b")},
|
|
130
|
+
}
|
|
131
|
+
servers = {sid: serve_on(Server) for sid in shapes if sid != "localai"}
|
|
132
|
+
port = {sid: s.server_port for sid, s in servers.items()}
|
|
133
|
+
port["localai"] = shared = port["llamacpp"] # as 8080 is both
|
|
134
|
+
for sid, shape in shapes.items():
|
|
135
|
+
if sid != "localai":
|
|
136
|
+
SHAPES[port[sid]] = shape
|
|
137
|
+
hung = socket.socket() # takes the connection, never answers
|
|
138
|
+
hung.bind(("127.0.0.1", 0))
|
|
139
|
+
hung.listen()
|
|
140
|
+
saved = (dict(connect.LOCAL), connect.listeners, os.environ.pop("OLLAMA_HOST", None))
|
|
141
|
+
connect.listeners = lambda: []
|
|
142
|
+
try:
|
|
143
|
+
for sid in shapes:
|
|
144
|
+
connect.LOCAL[sid] = {**saved[0][sid], "ports": (port[sid],)}
|
|
145
|
+
connect.LOCAL["hung"] = {"label": "Hung", "ports": (hung.getsockname()[1],), "kind": "local", "docs": "https://x"}
|
|
146
|
+
|
|
147
|
+
def found(**kw) -> dict:
|
|
148
|
+
return {f["id"]: f for f in connect.detect(**kw)}
|
|
149
|
+
|
|
150
|
+
start = time.time()
|
|
151
|
+
got = found()
|
|
152
|
+
# 0.5 s per probe: a hung port that held it up would take far longer; CI runners are slow.
|
|
153
|
+
assert time.time() - start < 5, "a hung port held detection up"
|
|
154
|
+
assert set(got) == set(shapes) - {"localai"}, got # 8080 answering as llama.cpp
|
|
155
|
+
assert got["llamacpp"]["port"] == shared and got["lmstudio"]["models"] == 3, got
|
|
156
|
+
for sid in ("jan", "litellm", "cliproxy"):
|
|
157
|
+
assert got[sid]["key"] and got[sid]["models"] is None, got[sid]
|
|
158
|
+
assert (got["litellm"]["kind"], got["litellm"]["hub"]) == ("hub", "litellm")
|
|
159
|
+
assert (got["cliproxy"]["kind"], got["cliproxy"]["hub"]) == ("hub", "cliproxy")
|
|
160
|
+
assert got["omniroute"]["kind"] == "aggregator" and got["vllm"]["kind"] == "local"
|
|
161
|
+
assert got["vllm"]["base"] == f"http://127.0.0.1:{port['vllm']}/v1"
|
|
162
|
+
assert got["ollama"]["provider"] == "ollama" and got["ollama"]["base"] == connect.PROVIDERS["ollama"]["base"]
|
|
163
|
+
SHAPES[shared] = shapes["localai"] # the same port, answering as LocalAI
|
|
164
|
+
assert "localai" in found() and "llamacpp" not in found()
|
|
165
|
+
# Something else on a server's port: a web page, an API without the server's sign.
|
|
166
|
+
SHAPES[shared] = {"/v1/models": (200, "<html>a web app</html>")}
|
|
167
|
+
SHAPES[port["vllm"]] = {"/v1/models": listing(None)}
|
|
168
|
+
assert not {"localai", "llamacpp", "vllm"} & set(found())
|
|
169
|
+
# This install's own port, and servers already endpoints (by localhost, or by this machine's name).
|
|
170
|
+
assert "omniroute" not in found(skip={port["omniroute"]})
|
|
171
|
+
connect.locked.update(connect.endpoints.FILE, lambda d: d.update(
|
|
172
|
+
mine={"base": f"http://localhost:{port['lmstudio']}/v1", "key": "mine"},
|
|
173
|
+
lan={"base": f"http://{socket.gethostname()}:{port['jan']}/v1", "key": "lan"}))
|
|
174
|
+
listed = {f["id"]: f for f in serve.connect_local()["found"]}
|
|
175
|
+
assert not {"lmstudio", "jan"} & set(listed) and "ollama" in listed, listed
|
|
176
|
+
assert listed["ollama"]["name"] == "ollama" and listed["litellm"]["name"] == "litellm", listed
|
|
177
|
+
# What each says of its own models, for the catalog: LM Studio's listing by every id a
|
|
178
|
+
# request can name, Ollama's /api/show a model at a time, and nothing from anything else.
|
|
179
|
+
import catalog
|
|
180
|
+
connect.locked.update(connect.endpoints.FILE, lambda d: d.update(
|
|
181
|
+
olla={"base": f"http://127.0.0.1:{port['ollama']}/v1", "key": "olla"},
|
|
182
|
+
vl={"base": f"http://127.0.0.1:{port['vllm']}/v1/", "key": "vl"}))
|
|
183
|
+
said, facts = catalog._native("mine", ["ignored"])
|
|
184
|
+
seen = {"context_window": 4096, "vision": True, "tools": False, "reasoning": False}
|
|
185
|
+
assert said == "lmstudio /api/v1/models" and facts["pub/m0"] == facts["pub/m0@q4_k_m"] == \
|
|
186
|
+
facts["pub/m0:2"] == seen, facts
|
|
187
|
+
assert facts["m1"] == {"context_window": 8192, "vision": False, "tools": True, "reasoning": True}, facts
|
|
188
|
+
assert catalog._native("olla", ["q:2b"]) == ("ollama /api/show", {"q:2b": {
|
|
189
|
+
"context_window": 262144, "vision": False, "tools": True, "reasoning": False}})
|
|
190
|
+
assert catalog._native("vl", ["m0"]) == ("", {})
|
|
191
|
+
# On any port, by the process listening there: LM Studio by its name, vLLM by its command
|
|
192
|
+
# line under Python, Ollama by OLLAMA_HOST (added at its real port, so not by its own
|
|
193
|
+
# flow, which knows only 11434); a port whose process is none of them is never asked.
|
|
194
|
+
extra = {sid: serve_on(Server) for sid in ("lmstudio", "vllm", "ollama", "other")}
|
|
195
|
+
servers.update({sid + "-moved": s for sid, s in extra.items()})
|
|
196
|
+
at = {sid: s.server_port for sid, s in extra.items()}
|
|
197
|
+
for sid in at:
|
|
198
|
+
SHAPES[at[sid]] = shapes["vllm" if sid == "other" else sid] # "other" answers as vLLM too
|
|
199
|
+
connect.listeners = lambda: [(1, at["lmstudio"], "LM Studio.exe", ""),
|
|
200
|
+
(2, at["vllm"], "python3", "/opt/venv/bin/python3 /opt/venv/bin/vllm serve m"),
|
|
201
|
+
(3, at["other"], "notepad.exe", "notepad.exe a.txt")]
|
|
202
|
+
os.environ["OLLAMA_HOST"] = f"0.0.0.0:{at['ollama']}"
|
|
203
|
+
moved = {(f["id"], f["port"]): f for f in connect.detect()}
|
|
204
|
+
assert {("lmstudio", at["lmstudio"]), ("vllm", at["vllm"]), ("ollama", at["ollama"])} <= set(moved), moved
|
|
205
|
+
assert at["other"] not in {p for _, p in moved}, moved
|
|
206
|
+
ollama = moved[("ollama", at["ollama"])]
|
|
207
|
+
assert not ollama["provider"] and ollama["base"] == f"http://127.0.0.1:{at['ollama']}/v1", ollama
|
|
208
|
+
finally:
|
|
209
|
+
connect.LOCAL.clear()
|
|
210
|
+
connect.LOCAL.update(saved[0])
|
|
211
|
+
connect.listeners = saved[1]
|
|
212
|
+
os.environ.pop("OLLAMA_HOST", None)
|
|
213
|
+
if saved[2] is not None:
|
|
214
|
+
os.environ["OLLAMA_HOST"] = saved[2]
|
|
215
|
+
hung.close()
|
|
216
|
+
for s in servers.values():
|
|
217
|
+
s.shutdown()
|
|
218
|
+
|
|
219
|
+
|
|
73
220
|
def main() -> int:
|
|
74
221
|
provider, dash = serve_on(Provider), serve_on(serve.Handler)
|
|
75
222
|
fake = f"http://127.0.0.1:{provider.server_port}"
|
|
@@ -207,6 +354,8 @@ def main() -> int:
|
|
|
207
354
|
assert set(json.loads(serve.DISMISSED_PATH.read_text(encoding="utf-8"))) == {"m2@hub", "m3"}
|
|
208
355
|
assert not serve.remove_endpoint("fk-2")["ok"] and not serve.set_default("nope")["ok"]
|
|
209
356
|
assert sum(1 for e in serve.endpoints.load().values() if e.get("default")) == 1
|
|
357
|
+
|
|
358
|
+
local_servers()
|
|
210
359
|
finally:
|
|
211
360
|
connect.PROVIDERS.clear()
|
|
212
361
|
connect.PROVIDERS.update(saved[0])
|
|
@@ -94,9 +94,11 @@ def main() -> int:
|
|
|
94
94
|
assert "cannot be read" in get("/"), "a broken endpoints file read as none"
|
|
95
95
|
broken.unlink()
|
|
96
96
|
|
|
97
|
-
# 1. No endpoint: the page renders and leads into Add
|
|
97
|
+
# 1. No endpoint: the page renders and leads into Add (on the Endpoints
|
|
98
|
+
# page, which bench.js fetches when the step opens it); the APIs answer.
|
|
98
99
|
page = get("/")
|
|
99
|
-
assert "id='first-run' data-step='endpoint'" in page and "id='e-add'" in
|
|
100
|
+
assert "id='first-run' data-step='endpoint'" in page and "id='e-add'" in get("/api/pane?tab=endpoints")["html"], \
|
|
101
|
+
"no first-run step"
|
|
100
102
|
assert get("/api/endpoints")["endpoints"] == []
|
|
101
103
|
assert get("/api/connect")["providers"], "no providers to sign in with"
|
|
102
104
|
cand = get("/api/candidates")
|
package/scripts/test_formats.py
CHANGED
|
@@ -274,7 +274,8 @@ def gateway_routes() -> None:
|
|
|
274
274
|
"merge": {"members": ["m1", "m2"], "reply": "merged", "merger": "mg", "description": "x"},
|
|
275
275
|
"broken": {"members": ["m1", "boom"], "description": "x"},
|
|
276
276
|
"small": {"members": ["tiny"], "description": "x"},
|
|
277
|
-
"other": {"members": ["m2"], "description": "x"}
|
|
277
|
+
"other": {"members": ["m2"], "description": "x"},
|
|
278
|
+
"dear": {"members": ["m1"], "description": "held back", "ad_hoc": True}})
|
|
278
279
|
import gateway
|
|
279
280
|
import guard
|
|
280
281
|
import serve
|
|
@@ -493,6 +494,65 @@ def gateway_routes() -> None:
|
|
|
493
494
|
one = json.loads(ok("/v1beta/models/bench/core")[0])
|
|
494
495
|
assert one["name"] == "models/bench/core" and "generateContent" in one["supportedGenerationMethods"]
|
|
495
496
|
assert call("/v1beta/models/bench/nope")[0] == 404
|
|
497
|
+
# Ad hoc: on no list and in no refusal, so nobody picks it; named, it runs and can be looked up.
|
|
498
|
+
for listing in (ok("/v1/models")[0], ok("/api/tags")[0], ok("/v1beta/models")[0]):
|
|
499
|
+
assert "bench/dear" not in listing and "bench/core" in listing, listing
|
|
500
|
+
assert "bench/dear" not in call("/v1/chat/completions", {"model": "bench/nope", "messages": [
|
|
501
|
+
{"role": "user", "content": user}]})[2]
|
|
502
|
+
assert ok("/api/show", {"model": "bench/dear"}) and ok("/v1beta/models/bench/dear")
|
|
503
|
+
asked = json.loads(ok("/v1/chat/completions", {"model": "bench/dear", "messages": [
|
|
504
|
+
{"role": "user", "content": user}]})[0])
|
|
505
|
+
assert asked["model"] == "bench/dear" and [s["alias"] for s in asked["bench"]["seats"]] == ["m1"], asked
|
|
506
|
+
seats.asked.clear()
|
|
507
|
+
made = call("/api/collection", {"name": "dear", "members": ["m1"], "ad_hoc": True}, on=dash.server_port)
|
|
508
|
+
assert json.loads(made[2])["ok"] and serve.bench_collections.load()["dear"]["ad_hoc"] is True, made
|
|
509
|
+
made = call("/api/collection", {"name": "dear", "members": ["m1"]}, on=dash.server_port)
|
|
510
|
+
assert json.loads(made[2])["ok"] and "bench/dear" in ok("/v1/models")[0], made # unticked: on offer again
|
|
511
|
+
serve.delete_collection("dear")
|
|
512
|
+
|
|
513
|
+
# A COLLECTION'S OWN LIMITS: above the gateway's 3 and 0 here, checked, undone by null.
|
|
514
|
+
def save(name, members, **own):
|
|
515
|
+
made = call("/api/collection", {"name": name, "members": members, "shape": "general", **own}, on=dash.server_port)
|
|
516
|
+
return json.loads(made[2])
|
|
517
|
+
def ask_it(name):
|
|
518
|
+
return json.loads(ok("/v1/chat/completions", {"model": f"bench/{name}", "messages": [
|
|
519
|
+
{"role": "user", "content": "x"}], "stream": False})[0])
|
|
520
|
+
for bad in ({"max_calls": 11}, {"max_calls": 0}, {"max_explorers": 3}, {"max_calls": "4"}, {"max_explorers": True}):
|
|
521
|
+
assert not save("own", ["m1"], **bad)["ok"], bad
|
|
522
|
+
assert not save("own", ["m1", "m2", "mg"], max_calls=2)["ok"] # its own cap, not the gateway's
|
|
523
|
+
assert save("wide", ["m1", "m2", "mg", "boom"], max_calls=4)["ok"] # above the gateway's
|
|
524
|
+
assert "wide" in serve.bench_collections.load() and not serve.save_gateway_settings(1, 0)["errors"][0].count("wide")
|
|
525
|
+
assert save("own", ["m1", "m2"], max_calls=4, max_explorers=1)["ok"]
|
|
526
|
+
listed_own = [m for m in json.loads(ok("/v1/models")[0])["data"] if m["id"] == "bench/own"][0]
|
|
527
|
+
assert listed_own["explorers"] == 1 and listed_own["max_calls"] == 4, listed_own
|
|
528
|
+
# (boom held throughout: the stub raises for it, and an explorer seat could pick it.)
|
|
529
|
+
real_refused = gateway.refused_now
|
|
530
|
+
gateway.refused_now = lambda aliases, *_: {a: "out of quota" for a in aliases if a == "boom"}
|
|
531
|
+
assert len(ask_it("own")["bench"]["seats"]) == 3 # two members, its own explorer
|
|
532
|
+
# STAND-INS: a held member stays on the collection, named, with a seat in its place.
|
|
533
|
+
gateway.refused_now = lambda aliases, *_: {a: "out of quota" for a in aliases if a in ("m1", "boom")}
|
|
534
|
+
b = ask_it("own")["bench"]
|
|
535
|
+
by_seat = {s["seat"]: s["alias"] for s in b["seats"]}
|
|
536
|
+
assert [k["alias"] for k in b["skipped"]] == ["m1"] and "m1" not in by_seat.values(), b
|
|
537
|
+
assert len(b["seats"]) == 3 and "stand-in for m1" in by_seat and by_seat["stand-in for m1"] != "m2", b
|
|
538
|
+
assert "m1" in serve.bench_collections.load()["own"]["members"]
|
|
539
|
+
# Max explorers 0: only the members, here and when the gateway's own 0 applies.
|
|
540
|
+
assert save("own", ["m1", "m2"], max_calls=4, max_explorers=0)["ok"]
|
|
541
|
+
assert [s["alias"] for s in ask_it("own")["bench"]["seats"]] == ["m2"]
|
|
542
|
+
assert save("own", ["m1", "m2"], max_calls=None, max_explorers=None)["ok"]
|
|
543
|
+
assert "max_calls" not in serve.bench_collections.load()["own"]
|
|
544
|
+
assert [s["alias"] for s in ask_it("own")["bench"]["seats"]] == ["m2"]
|
|
545
|
+
gateway.refused_now = lambda aliases, *_: {a: "out of quota" for a in aliases}
|
|
546
|
+
assert call("/v1/chat/completions", {"model": "bench/own", "messages": [{"role": "user", "content": "x"}]})[0] == 429
|
|
547
|
+
gateway.refused_now = real_refused
|
|
548
|
+
# A connection the alerts say has stopped answering counts as held.
|
|
549
|
+
import alerts
|
|
550
|
+
real_active, alerts.active = alerts.active, lambda: {"c": {"kind": "connection", "message": "not answering", "aliases": ["m1"]}}
|
|
551
|
+
assert gateway.refused_now(["m1", "m2"], 10, 1) == {"m1": "not answering"}
|
|
552
|
+
alerts.active = real_active
|
|
553
|
+
serve.delete_collection("own")
|
|
554
|
+
serve.delete_collection("wide")
|
|
555
|
+
seats.asked.clear()
|
|
496
556
|
# Token counts are estimates, without a model call; an image counts IMAGE_TOKENS.
|
|
497
557
|
counted = {"model": "bench/core", "messages": [
|
|
498
558
|
{"role": "user", "content": [{"type": "text", "text": "x" * 400},
|
|
@@ -505,6 +565,45 @@ def gateway_routes() -> None:
|
|
|
505
565
|
assert n == {"totalTokens": F.estimate("x" * 400)}, n
|
|
506
566
|
assert not seats.asked
|
|
507
567
|
|
|
568
|
+
# NO COLLECTION NAMED: a decision model sure enough picks one (not ad hoc, one the key may
|
|
569
|
+
# call), and the reply says which and why; unsure, or switched off, it is refused as before.
|
|
570
|
+
real_pick, asked_pick = gateway.decide.pick, []
|
|
571
|
+
|
|
572
|
+
def pick(name, state, options, use, answer=None):
|
|
573
|
+
asked_pick.append((name, state, set(options), use))
|
|
574
|
+
return answer or {"choice": "other", "confidence": 0.9}, "dm", ""
|
|
575
|
+
gateway.decide.pick = pick
|
|
576
|
+
unnamed = {"messages": [{"role": "system", "content": "Be brief."}, {"role": "user", "content": "pick for me"}]}
|
|
577
|
+
try:
|
|
578
|
+
text, h = ok("/v1/chat/completions", unnamed)
|
|
579
|
+
r = json.loads(text)
|
|
580
|
+
assert r["model"] == "bench/other" and r["bench"]["picked"]["collection"] == "bench/other", r
|
|
581
|
+
assert h["X-Bench-Picked"] == "bench/other; dm, 0.90", h
|
|
582
|
+
assert "Collection bench/other chosen for this request: no collection was named, so dm chose it" \
|
|
583
|
+
in r["choices"][0]["message"]["content"], r
|
|
584
|
+
name, state, options, use = asked_pick[-1]
|
|
585
|
+
assert (name, state, use) == ("collection", "Be brief.\n\npick for me", "pick_collection"), asked_pick
|
|
586
|
+
assert "other" in options and "core" in options and "dear" not in options, options
|
|
587
|
+
logged = json.loads((TMP / "runs" / "gateway" / "calls.jsonl").read_text(encoding="utf-8").splitlines()[-1])
|
|
588
|
+
assert logged["picked"]["by"] == "dm", logged # the benchmark never learns from it
|
|
589
|
+
status, h, text, _ = call("/v1/chat/completions", {**unnamed, "stream": True})
|
|
590
|
+
assert status == 200 and h["X-Bench-Picked"].startswith("bench/other;") and "picked" in text, (h, text)
|
|
591
|
+
text, h = ok("/v1/messages", {"max_tokens": 8, "messages": [{"role": "user", "content": "pick for me"}]})
|
|
592
|
+
assert json.loads(text)["model"] == "bench/other" and h["X-Bench-Picked"], text
|
|
593
|
+
gateway.decide.pick = lambda *a: pick(*a, answer={"choice": "other", "confidence": 0.5})
|
|
594
|
+
status, h, text, _ = call("/v1/chat/completions", unnamed)
|
|
595
|
+
assert status == 400 and "No collection was named" in text and "X-Bench-Picked" not in h, text
|
|
596
|
+
write("gateway.json", {"max_calls": 3, "max_explorers": 0, "pick_confidence": 0.4}) # its own cutoff
|
|
597
|
+
assert call("/v1/chat/completions", unnamed)[0] == 200
|
|
598
|
+
write("gateway.json", {"max_calls": 3, "max_explorers": 0, "pick_collection": False})
|
|
599
|
+
asked_pick.clear()
|
|
600
|
+
assert call("/v1/chat/completions", unnamed)[0] == 400 and not asked_pick # off: not even asked
|
|
601
|
+
assert call("/v1/chat/completions", {**unnamed, "model": "bench/nope"})[0] == 404 and not asked_pick
|
|
602
|
+
finally:
|
|
603
|
+
gateway.decide.pick = real_pick
|
|
604
|
+
write("gateway.json", {"max_calls": 3, "max_explorers": 0})
|
|
605
|
+
seats.asked.clear()
|
|
606
|
+
|
|
508
607
|
# The dashboard's port answers Ollama's /api/ routes as the gateway's, beside its own.
|
|
509
608
|
text, _ = ok("/api/tags", on=dash.server_port)
|
|
510
609
|
assert "bench/core" in text
|