@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/gateway.py CHANGED
@@ -11,9 +11,13 @@ is called every day:
11
11
 
12
12
  Collections and those limits live in `config/collections.json` and
13
13
  `config/gateway.json` (see `metrics/bench_collections.py`), edited from the
14
- dashboard. There is no default: a request that names no collection, or one
15
- that does not exist, is refused with every collection's description and tags,
16
- so the caller can choose — `GET /v1/models` carries the same.
14
+ dashboard. There is no default. A request that names no collection gets one
15
+ a decision model chooses from the collections' descriptions and tags, when
16
+ it is sure (pick_collection; gateway.json `pick_collection`, on unless
17
+ false), and the reply says which and why (`bench.picked`, X-Bench-Picked).
18
+ Otherwise, and for a collection that does not exist, it is refused with
19
+ every collection's description and tags, so the caller can choose — `GET
20
+ /v1/models` carries the same.
17
21
 
18
22
  The reply is every answer, each labelled by seat and model with a call id,
19
23
  including seats that failed or came back empty: who answered is evidence about
@@ -30,8 +34,9 @@ one-line note: the panel the caller waited for is never lost.
30
34
 
31
35
  A collection's `shape` (review, design, …) decides which models may fill the
32
36
  explorer seats. Left empty, a decision model labels the prompt when one that
33
- takes choice questions is onboarded (not ad hoc); otherwise, or on any doubt,
34
- the shape is `general`. A request body may add `"with": ["<alias>"]` to seat a
37
+ takes choice questions is onboarded (not ad hoc): the one metrics/decide.py
38
+ choose() names for the shape use; otherwise, or on any doubt, the shape is
39
+ `general`. A request body may add `"with": ["<alias>"]` to seat a
35
40
  model in an explorer chair, as `route.py --with` does.
36
41
 
37
42
  A retired model (`config/retired.json`) is never called: retiring takes it out
@@ -155,19 +160,10 @@ import model_roles # noqa: E402
155
160
  import offload # noqa: E402
156
161
  import route # noqa: E402
157
162
 
158
- # What the shape picker is asked to choose between. Each option carries a
159
- # description because bare names mislead it: Mercury Decide called "list every
160
- # place serve.py writes a file" `code` at 0.93 when given names alone.
161
- SHAPES = {
162
- "general": "A general question or task that fits none of the other kinds.",
163
- "design": "Architecture, process or approach decisions: weighing options, an ADR, a plan.",
164
- "prose": "Writing or editing documentation or other text.",
165
- "review": "Judging whether given code is correct: finding bugs, or where a guarantee breaks.",
166
- "code": "Writing new code or changing existing code.",
167
- "enumeration": "Listing every place something happens in given code or files.",
168
- "quick": "A short, bounded task: classify, extract or summarise a small input.",
169
- }
170
- CONFIDENT = 0.6
163
+ # What the shape picker chooses between, and how sure it must be: in
164
+ # metrics/decide.py, whose benchmark asks the same question.
165
+ SHAPES = decide.SHAPES
166
+ CONFIDENT = decide.CONFIDENT
171
167
  LARGE = 8000 # characters; route.py keeps models that fail big prompts off big tasks
172
168
  # Bytes. A million-token prompt is about 4 MB, and a photo sent as a data URL
173
169
  # is 4/3 of its file. 20 MB is the tightest whole-request limit among the big
@@ -177,9 +173,9 @@ TOO_BIG = (" Send images as http(s) URLs, or smaller ones: models scale a photo
177
173
  " 1,500-2,000 pixels on its long side anyway.")
178
174
  IMAGE_TOKENS = 2_500 # per image, in the size check (estimate_tokens says why)
179
175
  IMAGE_URL = re.compile(r"(https?://[^\s/?#]+|data:image/[\w.+-]+;base64,)", re.I)
180
- DATA_URLS = re.compile(r"data:[\w/.+-]*;base64,[A-Za-z0-9+/=_\\-]*", re.I)
181
- URLS = re.compile(r"https?://[^\s\"'<>\\]+", re.I)
182
- BASE64 = re.compile(r"[A-Za-z0-9+/]{100,}={0,2}")
176
+ # How an image or a URL is written down: in metrics/client.py, which writes a
177
+ # failed call's error to the ledger and so redacts it too.
178
+ from client import placeholder, redact # noqa: E402
183
179
  HEADER_NAMES = re.compile(r"[A-Za-z0-9-]{1,64}(\s*,\s*[A-Za-z0-9-]{1,64}){0,31}")
184
180
  SEAT_KEYS = ("call", "seat", "alias", "status", "seconds", "answer", "error")
185
181
  QUERY_KEY = re.compile(r"([?&](?:key|api_key|access_token)=)[^&\s'\"]*", re.I)
@@ -243,32 +239,6 @@ def images(messages: list[dict]) -> list[str]:
243
239
  return urls
244
240
 
245
241
 
246
- def placeholder(url: str) -> str:
247
- """How an image is written down (the call log, offloads, the dashboard,
248
- error text, the merger's copy of the request), never its bytes: a data
249
- URL by its size, an http URL without its query (a signed URL's signature
250
- is a credential) or a user:password."""
251
- if url[:5].lower() == "data:":
252
- return f"[image: data URL, {(len(url) * 3 // 4 + 999) // 1000:,} KB]"
253
- u = urllib.parse.urlsplit(url)
254
- return f"[image: {u.scheme}://{u.netloc.rpartition('@')[2]}{u.path[:200]}{'?...' if u.query else ''}]"
255
-
256
-
257
- def redact(text: str) -> str:
258
- """`text` with each inline image as its placeholder, every http(s) URL
259
- without its query or user:password, and any other long base64 run as its
260
- size: an upstream may echo the request in an error, in whatever form, and
261
- errors are logged, shown and learned from."""
262
- def bare(m):
263
- try:
264
- u = urllib.parse.urlsplit(m.group())
265
- except ValueError: # an unparseable netloc, such as "[::1"
266
- return "[url]"
267
- return f"{u.scheme}://{u.netloc.rpartition('@')[2]}{u.path}{'?...' if u.query else ''}"
268
- text = URLS.sub(bare, DATA_URLS.sub(lambda m: placeholder(m.group()), text))
269
- return BASE64.sub(lambda m: f"[base64, {(len(m.group()) * 3 // 4 + 999) // 1000:,} KB]", text)
270
-
271
-
272
242
  def cannot_see(alias: str) -> str:
273
243
  """Why an image cannot be put to `alias`, or "" when it can. The catalog
274
244
  decides (metrics/catalog.py: the model's input types, your Vision / OCR
@@ -289,29 +259,45 @@ def cannot_see(alias: str) -> str:
289
259
 
290
260
 
291
261
  def pick_shape(prompt: str) -> tuple[str, str]:
292
- """(shape, how it was chosen). Falls back to general on any doubt."""
293
- try:
294
- held = set(json.loads((DATA / "config" / "model-roles.json").read_text(encoding="utf-8"))
295
- .get("ad_hoc", []))
296
- except (OSError, ValueError, AttributeError):
297
- return "general", "config/model-roles.json unreadable"
298
- picker = next((a for a, d in decide.load().items()
299
- if "choice" in d["question_types"] and a not in held), None)
300
- if not picker:
301
- return "general", "no decision model onboarded"
302
- question = {"shape": {"type": "choice",
303
- "instructions": "What kind of task does this prompt ask for?",
304
- "criteria": SHAPES}}
305
- try:
306
- answer = decide.ask(picker, prompt[:20000], question)["answers"]["shape"]
307
- except Exception as exc: # noqa: BLE001
308
- return "general", f"{picker} failed ({decide.error_text(exc)[:80]})"
309
- conf = answer.get("confidence", 0)
262
+ """(shape, how it was chosen). The decision model decide.choose names for
263
+ the shape use is asked (ranked on the collection set, never a `:free` one
264
+ while another answers; the first onboarded before any is ranked), and the
265
+ call is logged. Falls back to general on any doubt."""
266
+ answer, picker, why = decide.pick("shape", prompt, SHAPES, "pick_shape")
267
+ if why:
268
+ return "general", why
269
+ conf = float(answer.get("confidence") or 0)
310
270
  if answer.get("choice") not in SHAPES or conf < CONFIDENT:
311
271
  return "general", f"{picker} unsure ({answer.get('choice')} at {conf:.2f})"
312
272
  return answer["choice"], f"{picker}, {conf:.2f}"
313
273
 
314
274
 
275
+ def pick_collection(messages: list[dict], who: str) -> tuple[str | None, dict]:
276
+ """For a request that names no collection: (the collection a decision
277
+ model chose for it, what the reply says about that), or (None, {}) when
278
+ gateway.json's `pick_collection` is off, nothing is on offer, or the model
279
+ is not sure (its confidence under `pick_confidence`, else CONFIDENT); the
280
+ request is then refused as before. The model best on the benchmark's
281
+ collection set chooses among the collections this key may call that are
282
+ not ad hoc, from their descriptions and tags, as choose_message lists them."""
283
+ s = bench_collections.pick_settings()
284
+ cols = {n: c for n, c in bench_collections.load().items() if not c.get("ad_hoc") and guard.may_call(who, n)}
285
+ if not s["pick_collection"] or not cols:
286
+ return None, {}
287
+ system, prompt = flatten(messages)
288
+ whole = decide.request_text({"system": system, "prompt": prompt}) # as the benchmark asks it
289
+ if not whole:
290
+ return None, {}
291
+ answer, by, why = decide.pick("collection", whole, decide.collection_options(cols), "pick_collection")
292
+ conf = float(answer.get("confidence") or 0)
293
+ if why or answer.get("choice") not in cols or conf < (s["pick_confidence"] or CONFIDENT):
294
+ return None, {}
295
+ name = answer["choice"]
296
+ return name, {"collection": bench_collections.PREFIX + name, "by": by, "confidence": round(conf, 2),
297
+ "why": f"no collection was named, so {by} chose it from the collections' descriptions and"
298
+ f" tags, at {conf:.2f}"}
299
+
300
+
315
301
  def retired() -> dict[str, dict]:
316
302
  """config/retired.json: alias -> its record (when, why). Retiring a model
317
303
  takes it out of every collection (serve.remove_model); this is for a
@@ -333,10 +319,21 @@ def retired_why(entry: dict) -> str:
333
319
  def refused_now(aliases, est_tokens: int, within: float) -> dict[str, str]:
334
320
  """alias -> why, for models whose known rate limits or quota leave no room
335
321
  for a call within `within` seconds (metrics/pacing.held): asked, they
336
- would only bring back the bench's own refusal, so they are not seated."""
322
+ would only bring back the bench's own refusal, so they are not seated.
323
+ So are models on a connection the alerts say has stopped answering: the
324
+ daily probes still try it, and the first answer ends the alert."""
337
325
  import pacing
338
326
  out = {}
327
+ try:
328
+ import alerts
329
+ down = {m: a.get("message") or "its connection is not answering"
330
+ for a in alerts.active().values() if a.get("kind") == "connection" for m in a.get("aliases") or []}
331
+ except Exception: # noqa: BLE001 -- a broken alert file is not a refusal
332
+ down = {}
339
333
  for a in aliases:
334
+ if a in down:
335
+ out[a] = down[a]
336
+ continue
340
337
  try:
341
338
  h = pacing.held(a, route.connection_of(a), est_tokens)
342
339
  except Exception: # noqa: BLE001 -- a broken limit file is not a refusal
@@ -379,9 +376,10 @@ def ask_seat(alias: str, system: str, prompt: str, messages: list[dict] | None =
379
376
  else:
380
377
  answer, error = _ask_llm(cmd, prompt)
381
378
  # A quota or credit notice delivered as the reply's text, with a success
382
- # exit, is not an answer (suite/run.py PROVIDER_NOTICES).
379
+ # exit, is not an answer (suite/run.py PROVIDER_NOTICES; a short reply
380
+ # that only looks like one is put to a decision model, once per shape).
383
381
  from run import provider_notice
384
- if not error and (notice := provider_notice(answer)):
382
+ if not error and (notice := provider_notice(answer, ask=True)):
385
383
  error, answer = f"provider notice in a success reply ({notice}): {answer[:200]}", ""
386
384
  if messages is not None:
387
385
  # Before the error is cut, logged or learned from. ponytail: the answer
@@ -396,7 +394,8 @@ def ask_seat(alias: str, system: str, prompt: str, messages: list[dict] | None =
396
394
  except Exception: # noqa: BLE001
397
395
  pass
398
396
  status = "error" if error else ("empty" if not answer else "ok")
399
- return {"status": status, "answer": answer, "error": error[-500:],
397
+ # Its start and end: the start holds the HTTP status a long body's tail lost.
398
+ return {"status": status, "answer": answer, "error": client.clip(error),
400
399
  "seconds": round(time.monotonic() - started, 1)}
401
400
 
402
401
 
@@ -544,10 +543,13 @@ def section(seat: dict) -> str:
544
543
 
545
544
 
546
545
  def run_collection(name: str, messages: list[dict], nominated: list[str] | None = None,
547
- reply: str = "", who: str = "local", on_part=None) -> tuple[str, dict]:
546
+ reply: str = "", who: str = "local", on_part=None, picked: dict | None = None) -> tuple[str, dict]:
548
547
  """The panel's reply as markdown, and the same seats as data for the
549
548
  response's `bench` field. `reply`: the request's mode, "" for the
550
549
  collection's own. `who`: the gateway key that asked, for the call log.
550
+ `picked`: the request named no collection and a decision model chose
551
+ this one (pick_collection); the reply says so, in `bench.picked` and its
552
+ heading, and the call log keeps it (so the benchmark never learns from it).
551
553
 
552
554
  `on_part`, for a streaming caller: called with each piece of an
553
555
  every-answer reply as it is ready, the heading once the panel is seated,
@@ -560,7 +562,7 @@ def run_collection(name: str, messages: list[dict], nominated: list[str] | None
560
562
  if not prompt.strip():
561
563
  raise ValueError("no user message to put to the collection")
562
564
  collection = bench_collections.load()[name]
563
- s = bench_collections.settings()
565
+ s = bench_collections.limits(collection)
564
566
  raw = collection.get("members") or []
565
567
  # The editor dedupes and caps; a hand-edited file may not have.
566
568
  members = list(dict.fromkeys(m for m in raw if isinstance(m, str) and m))
@@ -615,18 +617,22 @@ def run_collection(name: str, messages: list[dict], nominated: list[str] | None
615
617
  # like the others; it would only come back as the refusal.
616
618
  held = refused_now([*sitting, *nominated], needed, SEAT_TIMEOUT)
617
619
  skipped += [{"seat": "member" if a in sitting else "with", "alias": a, "why": why} for a, why in held.items()]
620
+ # AND AN EXPLORER SITS IN FOR A HELD MEMBER, until its limit has room: the
621
+ # member stays on the collection, and this is decided per request. Each
622
+ # one is named on its seat. Max explorers 0 means only the members ever run.
623
+ lent = [a for a in sitting if a in held] if s["max_explorers"] else []
618
624
  sitting = [a for a in sitting if a not in held]
619
625
  nominated = [a for a in nominated if a not in held]
620
626
  if pictures and not collection.get("shape"):
621
627
  shape, how = "vision", "the request carries images"
622
- elif not n_explorers:
628
+ elif not n_explorers and not lent:
623
629
  shape, how = collection.get("shape") or "general", "no explorer seats to fill"
624
630
  elif collection.get("shape"):
625
631
  shape, how = collection["shape"], "set on the collection"
626
632
  else:
627
633
  shape, how = pick_shape(whole)
628
634
  seats = [(f"member{i}", "member", alias) for i, alias in enumerate(sitting, 1)]
629
- if n_explorers:
635
+ if n_explorers or lent:
630
636
  # `with` in the request body is route.py's --with: an explorer seat.
631
637
  from run import _registered_names
632
638
  avoid = retiring() | set(too_small(needed, _registered_names())) # explorers must fit too
@@ -635,9 +641,11 @@ def run_collection(name: str, messages: list[dict], nominated: list[str] | None
635
641
  if pictures:
636
642
  avoid |= {a for a in _registered_names() if cannot_see(a)} # and see
637
643
  p = route.panel(shape, large=len(whole) > LARGE, nominated=nominated or None,
638
- members=sitting, explorers=n_explorers, avoid=avoid)
644
+ members=sitting, explorers=n_explorers, avoid=avoid, stand_ins=len(lent))
639
645
  seats += [(key, label, p[key]["model"]) for key, label in
640
646
  (("explorer", "explorer"), ("explorer2", "explorer 2")) if p.get(key)]
647
+ seats += [(f"standin{i}", f"stand-in for {member}", r["model"])
648
+ for i, (member, r) in enumerate(zip(lent, p["stand_ins"]), 1)]
641
649
  if not seats and pictures:
642
650
  raise ValueError(f"no seat in bench/{name} can see images, so none is asked rather than answer"
643
651
  " blind: " + ("; ".join(f"{k['alias']} {k['why']}" for k in skipped) or "no members")
@@ -652,6 +660,8 @@ def run_collection(name: str, messages: list[dict], nominated: list[str] | None
652
660
  task = " ".join(prompt.split())[:90] + f" ({model}, {shape})"
653
661
  out = [f"Bench {model} {panel_id} · {len(sitting)} members + {len(seats) - len(sitting)}"
654
662
  f" explorers · explorer shape {shape} ({how})\n"]
663
+ if picked:
664
+ out.append(f"Collection {model} chosen for this request: {picked['why']}.\n")
655
665
  if skipped:
656
666
  out.append("Not asked: " + "; ".join(f"{k['alias']} ({k['why']})" for k in skipped) + "\n")
657
667
  emit = None if merged else on_part
@@ -674,7 +684,7 @@ def run_collection(name: str, messages: list[dict], nominated: list[str] | None
674
684
  record = {"panel": panel_id, "at": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
675
685
  "model": model, "shape": shape, "shape_by": how, "key": who, "system": system,
676
686
  "prompt": prompt, "seats": [done[i] for i in range(len(seats))],
677
- **({"skipped": skipped} if skipped else {})}
687
+ **({"skipped": skipped} if skipped else {}), **({"picked": picked} if picked else {})}
678
688
  out += [section(s) for s in record["seats"]]
679
689
  footer = ("---\nRecord a verdict per seat once checked against the code:\n"
680
690
  "`POST /bench/verdict` on this same server (see `bench.verdict`), or"
@@ -690,7 +700,7 @@ def run_collection(name: str, messages: list[dict], nominated: list[str] | None
690
700
  # A path, not a URL: the same server answered, whichever port or
691
701
  # proxy the caller came through (the dashboard serves these routes too).
692
702
  "verdict": 'POST /bench/verdict {"call", "useful": true|false, "note", "real": [...], "false": [...]}',
693
- **({"skipped": skipped} if skipped else {})}
703
+ **({"skipped": skipped} if skipped else {}), **({"picked": picked} if picked else {})}
694
704
  if merged:
695
705
  try:
696
706
  merger, note = merge(collection, members, shape, whole, record["seats"], task, panel_id)
@@ -716,16 +726,19 @@ def models() -> list[str]:
716
726
  return [bench_collections.PREFIX + n for n in bench_collections.load()]
717
727
 
718
728
 
719
- def listed(who: str) -> list[tuple[str, dict]]:
729
+ def listed(who: str, ad_hoc: bool = False) -> list[tuple[str, dict]]:
720
730
  """(model name, what it is for) for every collection key `who` may call,
721
731
  each three times: as set, and in each other reply mode, so a client that
722
732
  offers only listed models (an agent's model picker above all, for
723
733
  `:route`) can still pick any. What GET /v1/models, /api/tags and
724
- /v1beta/models list, each in its own shape."""
734
+ /v1beta/models list, each in its own shape. `ad_hoc`: with the collections
735
+ not on offer, for looking up one a client already named."""
725
736
  s, rows = bench_collections.settings(), []
726
737
  for n, c in bench_collections.load().items():
727
738
  if not guard.may_call(who, n): # a key limited to some collections sees those
728
739
  continue
740
+ if c.get("ad_hoc") and not ad_hoc: # runs when named; nobody is offered it to pick
741
+ continue
729
742
  d = bench_collections.described(n, c, s)
730
743
  rows.append((bench_collections.PREFIX + n, d))
731
744
  rows += [(f"{bench_collections.PREFIX}{n}:{other}", {**d, "reply": other})
@@ -742,6 +755,11 @@ def bench_headers(bench: dict) -> dict:
742
755
  "X-Bench-Seats": json.dumps([{k: s.get(k) for k in ("call", "seat", "alias", "status")} for s in seats])}
743
756
 
744
757
 
758
+ def picked_headers(picked: dict | None) -> dict:
759
+ """X-Bench-Picked, when a decision model chose the collection: which, by whom, how sure."""
760
+ return {"X-Bench-Picked": f"{picked['collection']}; {picked['by']}, {picked['confidence']:.2f}"} if picked else {}
761
+
762
+
745
763
  def refusal(exc: Exception) -> tuple[int, str]:
746
764
  """The status and message for a panel that could not run."""
747
765
  if isinstance(exc, TooLarge):
@@ -983,6 +1001,7 @@ def route_turn(name: str, plan: dict, ask: dict, who: str, emit) -> None:
983
1001
  return emit("done", {"collection": model, "reply": "route", "conversation": conv["conv"], "call": call,
984
1002
  "alias": alias, "turn": turn_no, **({"failed_over": tried} if tried else {}),
985
1003
  **({"skipped": plan["skipped"]} if plan["skipped"] else {}),
1004
+ **({"picked": plan["picked"]} if plan.get("picked") else {}),
986
1005
  "verdict": 'optional, once per conversation: POST /bench/verdict {"call", "useful", "note"}'})
987
1006
  _log_route(conv, conv["turns"], "", bench_collections.PREFIX + name, who, "", "error", None, tried,
988
1007
  "every member failed")
@@ -1044,7 +1063,7 @@ class Handler(BaseHTTPRequestHandler):
1044
1063
  and (origin := guard.cors_origin(headers, self.server.server_port)):
1045
1064
  self.send_header("Access-Control-Allow-Origin", origin)
1046
1065
  self.send_header("Access-Control-Expose-Headers",
1047
- "X-Bench-Panel, X-Bench-Seats, X-Bench-Conversation, X-Bench-Model")
1066
+ "X-Bench-Panel, X-Bench-Seats, X-Bench-Conversation, X-Bench-Model, X-Bench-Picked")
1048
1067
  self.send_header("Vary", "Origin")
1049
1068
  super().end_headers()
1050
1069
 
@@ -1098,7 +1117,7 @@ class Handler(BaseHTTPRequestHandler):
1098
1117
  return self._json(200, formats.gemini_models(listed(self.who)))
1099
1118
  if path.startswith("/v1beta/models/"):
1100
1119
  name = urllib.parse.unquote(path[len("/v1beta/models/"):])
1101
- row = dict(listed(self.who)).get(name)
1120
+ row = dict(listed(self.who, ad_hoc=True)).get(name)
1102
1121
  return self._json(200, formats.gemini_model(name, row)) if row else \
1103
1122
  self._error(404, f"{name!r} is not a collection here; GET /v1beta/models lists them", fmt=fmt)
1104
1123
  if guard.mcp_route(path): # no stream from the server: every reply is a POST's
@@ -1243,7 +1262,7 @@ class Handler(BaseHTTPRequestHandler):
1243
1262
  def _show(self, body: dict) -> None:
1244
1263
  """POST /api/show: Ollama's model card, for a collection the key may call."""
1245
1264
  name = formats.ollama_model(body.get("model") or body.get("name") or "")
1246
- rows = dict(listed(self.who))
1265
+ rows = dict(listed(self.who, ad_hoc=True))
1247
1266
  row = rows.get(name) or rows.get(bench_collections.PREFIX + str(name))
1248
1267
  if not row:
1249
1268
  return self._error(404, f"model {name!r} not found; GET /api/tags lists the collections",
@@ -1255,6 +1274,14 @@ class Handler(BaseHTTPRequestHandler):
1255
1274
  the key's scope and cap, then the panel, answered in that format."""
1256
1275
  model, suffix = bench_collections.split_reply(ask["model"])
1257
1276
  name = bench_collections.resolve(model)
1277
+ picked = {}
1278
+ if name is None and not model:
1279
+ # NO COLLECTION NAMED: a decision model may choose one (pick_collection); when it does
1280
+ # not, the refusal is the one there always was, and costs the key nothing.
1281
+ try:
1282
+ name, picked = pick_collection(ask["messages"], self.who)
1283
+ except Exception: # noqa: BLE001 -- refused as before, never a 500
1284
+ name, picked = None, {}
1258
1285
  if name is None:
1259
1286
  return self._error(400 if not model else 404, bench_collections.choose_message(model), fmt=fmt)
1260
1287
  model = bench_collections.PREFIX + name
@@ -1263,30 +1290,31 @@ class Handler(BaseHTTPRequestHandler):
1263
1290
  return self._error(403, f"the gateway key {self.who} may not call {model}; it may call {allowed}",
1264
1291
  fmt=fmt)
1265
1292
  try:
1266
- guard.spend(self.who) # counted once it names a collection it may call
1293
+ guard.spend(self.who) # counted once it names (or is given) a collection it may call
1267
1294
  except guard.Refused as r:
1268
1295
  return self._error(r.status, str(r), r.headers, fmt)
1269
1296
  reply = ask["reply"] or suffix # the body is the most specific, then the suffix, then the collection
1270
1297
  # Chat completions have always answered with the collection's name;
1271
1298
  # the other formats echo the name as asked, which their clients match.
1272
- shown = model if fmt == "chat" else ask["model"]
1299
+ shown = model if fmt == "chat" or picked else ask["model"]
1273
1300
  if (reply or bench_collections.load()[name].get("reply")) == "route":
1274
- return self._route(fmt, name, ask, shown, sse)
1301
+ return self._route(fmt, name, ask, shown, sse, picked)
1275
1302
  if ask["agentic"]:
1276
1303
  return self._error(400, formats.TOOLS, fmt=fmt)
1277
1304
  if ask["stream"]:
1278
1305
  out = formats.INBOUND[fmt].stream(shown, prompt_tokens(ask["messages"]), sse=sse)
1279
- return self._stream(fmt, name, ask, reply, out)
1306
+ return self._stream(fmt, name, ask, reply, out, picked)
1280
1307
  started = time.monotonic()
1281
1308
  try:
1282
- content, bench = run_collection(name, ask["messages"], ask["with"], reply, self.who)
1309
+ content, bench = run_collection(name, ask["messages"], ask["with"], reply, self.who,
1310
+ **({"picked": picked} if picked else {}))
1283
1311
  except (ValueError, LookupError) as exc: # TooLarge is a ValueError
1284
1312
  return self._error(*refusal(exc), fmt=fmt)
1285
1313
  usage = (prompt_tokens(ask["messages"]), estimate_tokens(content))
1286
1314
  self._json(200, formats.INBOUND[fmt].reply(content, shown, bench, usage, time.monotonic() - started),
1287
- bench_headers(bench) if fmt == "gemini" else None)
1315
+ {**(bench_headers(bench) if fmt == "gemini" else {}), **picked_headers(picked)} or None)
1288
1316
 
1289
- def _stream(self, fmt: str, name: str, ask: dict, reply: str, out: formats.Stream) -> None:
1317
+ def _stream(self, fmt: str, name: str, ask: dict, reply: str, out: formats.Stream, picked: dict | None = None) -> None:
1290
1318
  """The panel as it comes, in the format's stream shape: every answer
1291
1319
  seat by seat as each finishes, or a merged reply when the merge is
1292
1320
  done. The panel runs in its own thread and this one writes, so a
@@ -1300,7 +1328,7 @@ class Handler(BaseHTTPRequestHandler):
1300
1328
  def work():
1301
1329
  try:
1302
1330
  events.put(("done", run_collection(name, ask["messages"], ask["with"], reply, self.who,
1303
- on_part=lambda text: events.put(("part", text)))))
1331
+ on_part=lambda text: events.put(("part", text)), picked=picked)))
1304
1332
  except Exception as exc: # noqa: BLE001 -- answered below, as a status or in the stream
1305
1333
  events.put(("error", exc))
1306
1334
  threading.Thread(target=work, daemon=True).start()
@@ -1317,6 +1345,8 @@ class Handler(BaseHTTPRequestHandler):
1317
1345
  self.send_response(200)
1318
1346
  self.send_header("Content-Type", out.content_type)
1319
1347
  self.send_header("Cache-Control", "no-cache")
1348
+ for k, v in picked_headers(picked).items():
1349
+ self.send_header(k, v)
1320
1350
  self.end_headers()
1321
1351
  self.wfile.write(out.start())
1322
1352
  started = True
@@ -1335,7 +1365,7 @@ class Handler(BaseHTTPRequestHandler):
1335
1365
  except OSError:
1336
1366
  return # the caller left; the panel still finishes, every seat recorded
1337
1367
 
1338
- def _route(self, fmt: str, name: str, ask: dict, shown: str, sse: bool = True) -> None:
1368
+ def _route(self, fmt: str, name: str, ask: dict, shown: str, sse: bool = True, picked: dict | None = None) -> None:
1339
1369
  """Route mode: one member's answer, in the request's format. The
1340
1370
  turn runs in its own thread (route_turn) and this one writes: a
1341
1371
  whole reply once it is done, or a stream whose 200 waits for the
@@ -1345,6 +1375,7 @@ class Handler(BaseHTTPRequestHandler):
1345
1375
  plan = route_plan(name, ask, self.who)
1346
1376
  except (ValueError, LookupError) as exc: # TooLarge is a ValueError
1347
1377
  return self._error(*refusal(exc), fmt=fmt)
1378
+ plan["picked"] = picked or {}
1348
1379
  events: queue.Queue = queue.Queue()
1349
1380
 
1350
1381
  def work():
@@ -1354,7 +1385,7 @@ class Handler(BaseHTTPRequestHandler):
1354
1385
  events.put(("error", (500, f"route mode failed ({type(exc).__name__}: {exc})")))
1355
1386
  threading.Thread(target=work, daemon=True).start()
1356
1387
  tokens, began = route_tokens(ask), time.monotonic()
1357
- headers = {"X-Bench-Conversation": plan["conv"]["conv"]}
1388
+ headers = {"X-Bench-Conversation": plan["conv"]["conv"], **picked_headers(picked)}
1358
1389
  if not ask["stream"]:
1359
1390
  turn = formats.Turn()
1360
1391
  while True:
package/mcp_server.py CHANGED
@@ -52,7 +52,8 @@ TOOLS = [
52
52
  "title": "List collections",
53
53
  "description": "The collections this Benchwarmers server offers: name, what each is for, tags, members,"
54
54
  " explorer seats, reply mode, and what the members handle together (smallest context"
55
- " window, shared capabilities, tightest rate limits). Pick by task before calling ask.",
55
+ " window, shared capabilities, tightest rate limits). Pick by task before calling ask."
56
+ " A collection the user names may not be listed here: ask it anyway.",
56
57
  "inputSchema": {"type": "object", "properties": {}, "additionalProperties": False},
57
58
  "annotations": {"readOnlyHint": True, "openWorldHint": False}},
58
59
  {"name": "ask",
package/metrics/alerts.py CHANGED
@@ -76,6 +76,31 @@ def clear(key: str) -> bool:
76
76
  return _update(lambda data: data.pop(key, None) is not None)
77
77
 
78
78
 
79
+ def answered(alias: str, served: str) -> list[str]:
80
+ """A model answered: clear what an answer disproves. Every alert about it,
81
+ by its alias or a `model:<kind>:<served>` key (retiring, unrecognised
82
+ failure, never served, gone, entitlement, paywalled), except a price
83
+ (`model:paid:`), which an answer says nothing about. A held connection
84
+ loses it from its list, and goes once none is left. The cleared keys."""
85
+ def resolve(data: dict) -> list[str]:
86
+ gone = []
87
+ for key, a in list(data.items()):
88
+ if a.get("kind") == "connection":
89
+ held = a.get("aliases") or []
90
+ if alias not in held:
91
+ continue
92
+ a["aliases"] = [x for x in held if x != alias]
93
+ if a["aliases"]:
94
+ continue
95
+ elif key.startswith("model:paid:") or not (
96
+ a.get("alias") == alias or (key.startswith("model:") and key.split(":", 2)[2:] == [served])):
97
+ continue
98
+ del data[key]
99
+ gone.append(key)
100
+ return gone
101
+ return _update(resolve)
102
+
103
+
79
104
  def sweep(prefix: str, still_wrong: set[str]) -> list[str]:
80
105
  """Retire every alert under `prefix` that the caller did not re-raise.
81
106
 
@@ -3,7 +3,8 @@
3
3
  config/collections.json {"core": {"members": [...], "shape": "",
4
4
  "description": "...", "tags": ["general"],
5
5
  "reply": "all", "merger": ""}}
6
- config/gateway.json {"max_calls": 5, "max_explorers": 2}
6
+ config/gateway.json {"max_calls": 5, "max_explorers": 2,
7
+ "pick_collection": true, "pick_confidence": 0.6}
7
8
 
8
9
  A request for `bench/core` runs every member, plus explorer seats that
9
10
  `route.py` fills by staleness so the roster keeps getting evidence even when
@@ -11,6 +12,12 @@ the same collection is called every day:
11
12
 
12
13
  explorers = min(max_explorers, max_calls - members)
13
14
 
15
+ A collection may hold its own `max_calls` and `max_explorers`; what it leaves
16
+ out follows gateway.json, so changing that moves every collection without its
17
+ own. A member out of quota keeps its place and an explorer sits in for it
18
+ until the quota is back (gateway.py), unless max_explorers is 0: then only the
19
+ members ever run.
20
+
14
21
  `shape` (review, design, …) decides which models may fill those explorer
15
22
  seats; empty means a decision model labels each prompt, or `general` without
16
23
  one. `reply` is what the caller reads: `all` (every answer, the default) or
@@ -19,7 +26,9 @@ verdict record, gateway.py) or `route` (no panel: one member, picked by its
19
26
  record, gets the request as sent, tools and all, for agent clients;
20
27
  GATEWAY.md "Route mode"). A request may override it with a `:merged`,
21
28
  `:all` or `:route` suffix on the model name, or `"bench": {"reply": ...}`
22
- in the body.
29
+ in the body. `ad_hoc` (false unless set) keeps a collection out of everything
30
+ that offers collections to choose from: it runs when a request names it, as an
31
+ ad hoc model does (suite/run.py), for the expensive ones a person decides on.
23
32
  Named `bench_collections` because `collections` would shadow the standard
24
33
  library for everything importing from metrics/.
25
34
  """
@@ -35,7 +44,8 @@ from paths import DATA # noqa: E402 -- the install's data folder (metrics/path
35
44
  FILE = DATA / "config" / "collections.json"
36
45
  SETTINGS_FILE = DATA / "config" / "gateway.json"
37
46
  DEFAULTS = {"max_calls": 5, "max_explorers": 2}
38
- NAME = re.compile(r"^[a-z0-9][a-z0-9-]{1,31}$")
47
+ BOUNDS = {"max_calls": (1, 10), "max_explorers": (0, 2)} # route.py seats at most two explorers
48
+ NAME =re.compile(r"^[a-z0-9][a-z0-9-]{1,31}$")
39
49
  PREFIX = "bench/"
40
50
  REPLIES = ("all", "merged", "route")
41
51
 
@@ -67,6 +77,30 @@ def settings() -> dict:
67
77
  return {**DEFAULTS, **{k: v for k, v in _read(SETTINGS_FILE).items() if k in DEFAULTS}}
68
78
 
69
79
 
80
+ def pick_settings() -> dict:
81
+ """gateway.json's `pick_collection` (on unless false: a request that names
82
+ no collection gets one a decision model chooses, gateway.pick_collection)
83
+ and `pick_confidence` (above 0, at most 1; None means decide.CONFIDENT).
84
+ Kept beside the limits, which save_gateway_settings leaves alone."""
85
+ raw = _read(SETTINGS_FILE)
86
+ on, at = raw.get("pick_collection"), raw.get("pick_confidence")
87
+ return {"pick_collection": on is not False,
88
+ "pick_confidence": at if isinstance(at, (int, float)) and not isinstance(at, bool) and 0 < at <= 1 else None}
89
+
90
+
91
+ def limit_ok(key: str, value) -> bool:
92
+ return isinstance(value, int) and not isinstance(value, bool) and BOUNDS[key][0] <= value <= BOUNDS[key][1]
93
+
94
+
95
+ def own_limits(c: dict) -> dict:
96
+ """The limits a collection sets for itself; a hand-edited one out of bounds counts as unset."""
97
+ return {k: c[k] for k in DEFAULTS if limit_ok(k, c.get(k))}
98
+
99
+
100
+ def limits(c: dict, s: dict | None = None) -> dict:
101
+ return {**(s or settings()), **own_limits(c)}
102
+
103
+
70
104
  def explorers_for(members: int, s: dict | None = None) -> int:
71
105
  s = s or settings()
72
106
  return max(0, min(s["max_explorers"], s["max_calls"] - members))
@@ -81,8 +115,8 @@ def described(name: str, c: dict, s: dict | None = None) -> dict:
81
115
  members = c.get("members", [])
82
116
  out = {"description": c.get("description") or c.get("note", ""),
83
117
  "tags": c.get("tags", []), "members": members, "shape": c.get("shape", ""),
84
- "explorers": explorers_for(len(members), s),
85
- "reply": c.get("reply") or "all", "merger": c.get("merger", "")}
118
+ "explorers": explorers_for(len(members), limits(c, s)), **own_limits(c),
119
+ "reply": c.get("reply") or "all", "merger": c.get("merger", ""), "ad_hoc": bool(c.get("ad_hoc"))}
86
120
  # What the members can handle together (metrics/catalog.py): the smallest
87
121
  # context window is what the gateway refuses above.
88
122
  try:
@@ -95,8 +129,10 @@ def described(name: str, c: dict, s: dict | None = None) -> dict:
95
129
 
96
130
  def resolve(model: str | None) -> str | None:
97
131
  """The collection a request's `model` names: `bench/core` or `core`; None
98
- when it names nothing here. There is no default: a caller that did not
99
- choose is told what each collection is for and asked to choose."""
132
+ when it names nothing here. There is no default: a request with no model
133
+ gets one a decision model chooses when it is sure (gateway.pick_collection,
134
+ pick_settings), and is otherwise told what each collection is for and
135
+ asked to choose, as is one naming a collection that does not exist."""
100
136
  name = (model or "").strip().removeprefix(PREFIX) # "bench/" alone names nothing
101
137
  return name if name and name in load() else None
102
138
 
@@ -122,11 +158,11 @@ def reply_errors(reply: str, merger: str, known) -> list[str]:
122
158
 
123
159
  def choose_message(model: str | None) -> str:
124
160
  """The refusal for a request that names no collection, or the wrong one:
125
- every collection with its description and tags, so the caller can choose."""
161
+ every collection on offer with its description and tags, so the caller can choose."""
126
162
  asked = f"{model!r} is not a collection here." if model else "No collection was named."
127
163
  rows = [f"- {PREFIX}{n}: {c.get('description') or 'no description'}"
128
164
  + (f" [{', '.join(c.get('tags', []))}]" if c.get("tags") else "")
129
- for n, c in sorted(load().items())]
165
+ for n, c in sorted(load().items()) if not c.get("ad_hoc")]
130
166
  return (f"{asked} Set \"model\" to the collection that fits the task"
131
167
  " (GET /v1/models lists them with description and tags):\n"
132
168
  + ("\n".join(rows) or "- none yet: create one in the dashboard's Collections dialog"))
@@ -136,10 +172,23 @@ if __name__ == "__main__":
136
172
  # DEFAULTS, not settings(): the install's gateway.json must not decide the check.
137
173
  assert [explorers_for(n, DEFAULTS) for n in range(1, 7)] == [2, 2, 2, 1, 0, 0]
138
174
  assert explorers_for(3, {"max_calls": 4, "max_explorers": 2}) == 1
175
+ assert limits({"max_calls": 4, "max_explorers": 0}, DEFAULTS) == {"max_calls": 4, "max_explorers": 0}
176
+ assert limits({"max_calls": 99, "max_explorers": True}, DEFAULTS) == DEFAULTS and limits({}, DEFAULTS) == DEFAULTS
177
+ assert explorers_for(4, limits({"max_calls": 4}, DEFAULTS)) == 0
139
178
  assert resolve("bench/") is None and resolve("bench/no-such") is None and resolve("") is None
140
179
  assert split_reply("bench/core:merged") == ("bench/core", "merged") and split_reply("core:all") == ("core", "all")
141
180
  assert split_reply("bench/core:route") == ("bench/core", "route")
142
181
  assert split_reply("bench/core") == ("bench/core", "") and split_reply("bench/core:x") == ("bench/core:x", "")
182
+ import tempfile
183
+ real = SETTINGS_FILE
184
+ with tempfile.TemporaryDirectory() as tmp:
185
+ SETTINGS_FILE = pathlib.Path(tmp) / "gateway.json"
186
+ assert pick_settings() == {"pick_collection": True, "pick_confidence": None} # on by default
187
+ SETTINGS_FILE.write_text('{"pick_collection": false, "pick_confidence": 0.8}', encoding="utf-8")
188
+ assert pick_settings() == {"pick_collection": False, "pick_confidence": 0.8}
189
+ SETTINGS_FILE.write_text('{"pick_confidence": 2}', encoding="utf-8") # out of range: unset
190
+ assert pick_settings() == {"pick_collection": True, "pick_confidence": None}
191
+ SETTINGS_FILE = real
143
192
  assert not reply_errors("merged", "", []) and not reply_errors("all", "m", ["m"])
144
193
  assert len(reply_errors("", "m", [])) == 2
145
194
  print("ok")