@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.
@@ -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
- def evidence() -> tuple[list[dict], dict[str, dt.date], dict[str, dt.date]]:
205
- """(perf rows, last non-perf answer per alias, restore date per alias)."""
206
- history = locked.read_json(DATA / "config" / "alias-history.json") # {} until a rename
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
- def saw(alias: str, day: dt.date) -> None:
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
 
@@ -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
- run("scripts/sync_config.py", timeout=300)
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; connection is {connection}; {details}")
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
 
@@ -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, and
9
- models on a renewing endpoint routed to the built-in client. Endpoints, keys,
10
- the change log and connect.json all point at a temp folder.
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; the APIs answer.
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 page, "no first-run step"
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")
@@ -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