@wendellresearch-ai/benchwarmers 0.5.6 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.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 +81 -11
- 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 +720 -31
- package/metrics/endpoints.py +9 -7
- package/metrics/guard.py +6 -4
- package/metrics/learn.py +264 -10
- package/metrics/ledger.py +3 -1
- package/metrics/locked.py +102 -1
- package/metrics/offload.py +36 -19
- package/metrics/pacing.py +8 -1
- package/metrics/quotaview.py +19 -5
- package/metrics/route.py +9 -1
- package/metrics/scheduler.py +11 -1
- package/metrics/test_decide.py +299 -7
- package/metrics/wrong.py +21 -9
- package/package.json +3 -1
- package/scripts/auto_retire.py +96 -21
- package/scripts/onboard.py +51 -4
- 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_quota.py +27 -0
- package/scripts/test_serve_edit.py +308 -4
- package/serve.py +1721 -145
- 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 +933 -203
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",
|
|
@@ -273,7 +286,13 @@ def main() -> None:
|
|
|
273
286
|
return
|
|
274
287
|
# The passthrough hints are about Open WebUI; anywhere else a 404 is the provider's.
|
|
275
288
|
owui = endpoints.hub(endpoint) == "openwebui"
|
|
276
|
-
|
|
289
|
+
# ponytail: Cloudflare told apart by its @cf/ ids; its partner models (no @cf/) get the generic hint.
|
|
290
|
+
hint = (" Cloudflare answers decisions only on its /ai/run route, which no hub relays: add"
|
|
291
|
+
" Cloudflare as a direct endpoint (decisions .../accounts/<id>/ai/run/{model},"
|
|
292
|
+
" models_url .../ai/models/search?per_page=1000) and onboard it from there.\n"
|
|
293
|
+
if "@cf/" in name and endpoint != "cloudflare" and "{model}" not in
|
|
294
|
+
(endpoints.load()[endpoint].get("decisions") or "") else
|
|
295
|
+
" Open WebUI relays decisions only with ENABLE_OPENAI_API_PASSTHROUGH=True"
|
|
277
296
|
" in its environment.\n" if owui and "passthrough" in why.lower() else
|
|
278
297
|
" Its connection has no /decisions under its base. An OpenRouter-hosted decision"
|
|
279
298
|
" model cannot go through Open WebUI (its Decisions API is outside /api/v1);"
|
|
@@ -305,7 +324,13 @@ def main() -> None:
|
|
|
305
324
|
lambda roles: roles.setdefault("models", {}).__setitem__(alias, "vision"),
|
|
306
325
|
ensure_ascii=False)
|
|
307
326
|
print(" role vision: a seat only for requests that carry images")
|
|
308
|
-
|
|
327
|
+
# The llm CLI's copy of the registry, only while it is the client: the
|
|
328
|
+
# built-in client reads the data folder's, and choosing the llm CLI on
|
|
329
|
+
# Settings syncs it whole (serve.save_client).
|
|
330
|
+
import client as model_client
|
|
331
|
+
synced = model_client.chosen()[0] == "llm"
|
|
332
|
+
if synced:
|
|
333
|
+
run("scripts/sync_config.py", timeout=300)
|
|
309
334
|
# Open WebUI's tags name the connection behind the hub; without a hub the
|
|
310
335
|
# endpoint is the connection, and route.connection_of needs no tags.
|
|
311
336
|
if endpoints.find_hub("openwebui"):
|
|
@@ -319,7 +344,8 @@ def main() -> None:
|
|
|
319
344
|
catalog = run("metrics/catalog.py", timeout=300)
|
|
320
345
|
details = ("details from the catalog" if catalog.returncode == 0
|
|
321
346
|
else f"catalog refresh failed (exit {catalog.returncode}); details at its next run")
|
|
322
|
-
print(f" [3/5] synced
|
|
347
|
+
print(f" [3/5] {'synced to the llm CLI' if synced else 'the llm CLI not synced (the built-in client is in use)'};"
|
|
348
|
+
f" connection is {connection}; {details}")
|
|
323
349
|
|
|
324
350
|
# An image generator: role image, which no chat shape admits, so it never
|
|
325
351
|
# fills a seat or a collection; a chat probe and the suite would only
|
|
@@ -341,6 +367,16 @@ def main() -> None:
|
|
|
341
367
|
" never a seat, not probed, no suite.\n")
|
|
342
368
|
return
|
|
343
369
|
|
|
370
|
+
# A decision model's suggestion for its role, from what the catalog knows:
|
|
371
|
+
# kept for the model popup (metrics/decided.json), never set from here.
|
|
372
|
+
try:
|
|
373
|
+
hint = decide.suggest_role(alias, role_facts(alias, name, connection))
|
|
374
|
+
print(f" role suggested by {hint['by']}: {hint['role']} at {hint['confidence']:.2f}"
|
|
375
|
+
" (a suggestion; the role is yours to set in its model popup)" if "role" in hint
|
|
376
|
+
else f" no role suggestion: {hint['error']}")
|
|
377
|
+
except Exception as exc: # noqa: BLE001 -- a suggestion never stops an onboarding
|
|
378
|
+
print(f" no role suggestion: {type(exc).__name__}: {exc}")
|
|
379
|
+
|
|
344
380
|
# 4. availability
|
|
345
381
|
print(" [4/5] probing availability (4 calls)...")
|
|
346
382
|
since = dt.datetime.now().isoformat(timespec="seconds")
|
|
@@ -414,6 +450,17 @@ def _selftest() -> None:
|
|
|
414
450
|
# The name fallback, for a model the catalog says nothing about.
|
|
415
451
|
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
452
|
assert not any(IMAGE_NAMES.search(s) for s in ("gpt-5.2", "reflux-7b", "gemini-3-pro"))
|
|
453
|
+
# What the role suggestion is told: the catalog's facts, the description last.
|
|
454
|
+
import catalog
|
|
455
|
+
real = catalog.info
|
|
456
|
+
catalog.info = lambda a: {"context_window": 131072, "vision": False, "input_modalities": ["text"],
|
|
457
|
+
"tools": None, "description": "A coding model."}
|
|
458
|
+
try:
|
|
459
|
+
facts = role_facts("m1", "vendor/m1", "groq").splitlines()
|
|
460
|
+
finally:
|
|
461
|
+
catalog.info = real
|
|
462
|
+
assert facts == ["model: vendor/m1", "connection: groq", "context window: 131072", "vision: False",
|
|
463
|
+
"input modalities: text", "description: A coding model."], facts
|
|
417
464
|
print("ok")
|
|
418
465
|
|
|
419
466
|
|
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
|
@@ -565,6 +565,45 @@ def gateway_routes() -> None:
|
|
|
565
565
|
assert n == {"totalTokens": F.estimate("x" * 400)}, n
|
|
566
566
|
assert not seats.asked
|
|
567
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
|
+
|
|
568
607
|
# The dashboard's port answers Ollama's /api/ routes as the gateway's, beside its own.
|
|
569
608
|
text, _ = ok("/api/tags", on=dash.server_port)
|
|
570
609
|
assert "bench/core" in text
|
package/scripts/test_quota.py
CHANGED
|
@@ -20,6 +20,8 @@ not mix. Covered:
|
|
|
20
20
|
a Gemini 429 body's daily quota learned as a limit;
|
|
21
21
|
- an rpm limit across six threads at once and across two processes (calls in
|
|
22
22
|
flight count), a monthly cap (rpmo), and both refusals classed as capacity;
|
|
23
|
+
- a connection's learned limit (metrics/learn.py) enforced, its refusal not
|
|
24
|
+
learned from, your own value winning over it, and Forget;
|
|
23
25
|
- the free-tier guard holding a model that went paid, until it is allowed;
|
|
24
26
|
- metrics/cost.py reading the ledger.
|
|
25
27
|
"""
|
|
@@ -331,6 +333,31 @@ def main() -> int:
|
|
|
331
333
|
check(not bad["ok"] and serve.save_model_facts("t-ok", {"limits": {"rpmo": 5}}).get("ok") is not None
|
|
332
334
|
and "rpmo" not in ((serve.catalog.overrides().get("t-ok") or {}).get("limits") or {}),
|
|
333
335
|
"a bad rpmo is refused; a model's own limits have none", bad)
|
|
336
|
+
# A connection's learned limit (learn.py: 429s that named no number) is kept to like one of yours, which wins.
|
|
337
|
+
import datetime as dt
|
|
338
|
+
at = dt.datetime.now().astimezone()
|
|
339
|
+
learn._learn_connection("monthprov", "rpd", 3, "test", "429", at - dt.timedelta(days=2))
|
|
340
|
+
got = learn._learn_connection("monthprov", "rpd", 3, "test", "429", at - dt.timedelta(days=1))
|
|
341
|
+
rule = [x for x in pacing.rules("t-month", "monthprov") if x["axis"] == "rpd"]
|
|
342
|
+
h = pacing.held("t-month", "monthprov")
|
|
343
|
+
check(got == {"rpd": 3} and len(rule) == 1 and rule[0]["scope"] == "connection" and rule[0]["limit"] == 3
|
|
344
|
+
and rule[0]["source"].startswith("learned ") and h is not None and h[0] > 3600 and "learned" in h[1],
|
|
345
|
+
"a connection's learned rpd is enforced", (got, rule, h))
|
|
346
|
+
r = client.chat("t-month", "hi", timeout=5)
|
|
347
|
+
learn.record("t-month", client.output(r, 5), "test")
|
|
348
|
+
kept = learn.connection_learned()["monthprov"]["rpd"]
|
|
349
|
+
check("rate limit (not sent" in r["error"] and "connection-learned.json" in r["error"]
|
|
350
|
+
and kept["confirmed_at"] == kept["at"] and not learn.connection_learned(pending=True),
|
|
351
|
+
"its refusal names where the limit is from, and is not learned from", (r["error"], kept))
|
|
352
|
+
view = {c["name"]: c for c in serve.connections_view()["connections"]}
|
|
353
|
+
serve.save_connection_limits("monthprov", {"rpm": 20, "rpmo": 1000, "rpd": 100})
|
|
354
|
+
rule = [x for x in pacing.rules("t-month", "monthprov") if x["axis"] == "rpd"]
|
|
355
|
+
check(view["monthprov"]["learned"] == {"rpd": {k: kept[k] for k in ("value", "at", "confirmed_at")}}
|
|
356
|
+
and len(rule) == 1 and rule[0]["limit"] == 100 and rule[0]["source"].startswith("you"),
|
|
357
|
+
"the Endpoints dialog shows it; your own value for that connection wins", (view["monthprov"], rule))
|
|
358
|
+
gone = serve.forget_connection_learned("monthprov", "rpd")
|
|
359
|
+
check(gone["ok"] and learn.connection_learned() == {} and not serve.forget_connection_learned("monthprov", "rpd")["ok"],
|
|
360
|
+
"Forget drops a learned limit", gone)
|
|
334
361
|
page = serve.quotas_page()
|
|
335
362
|
check("id='quota-data'" in page and "fakeprov" in page and "What the bench sent" in page,
|
|
336
363
|
"the Quotas page carries its tiles and the ledger's counts")
|