@wendellresearch-ai/benchwarmers 0.5.4 → 0.5.6

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/AGENTS.md CHANGED
@@ -95,7 +95,10 @@ cost with the person first. Three to five models make a useful first panel.
95
95
  ## 6. Make a collection, start the gateway
96
96
 
97
97
  On the Collections page: a name, the members a panel always includes, and the
98
- shape of work it is for (review, design, ...). Then:
98
+ shape of work it is for (review, design, ...). A collection can set its own
99
+ calls per request and explorers; one marked ad hoc runs only when a request
100
+ names it, never as a default, so keep the person's expensive models there.
101
+ Then:
99
102
 
100
103
  python gateway.py # http://127.0.0.1:8123/v1
101
104
 
package/GATEWAY.md CHANGED
@@ -43,6 +43,22 @@ model family. Collections live in `config/collections.json`, the limits in
43
43
  `config/gateway.json`; both are edited in the dashboard's
44
44
  **Collections** tab and every change is logged in `config/changes.jsonl`.
45
45
 
46
+ **A collection's own limits.** `max_calls` (1 to 10) and `max_explorers` (0 to 2)
47
+ can be set on a collection, in its dialog or with `bench collection save <name>
48
+ --max-calls 4 --max-explorers 0`. What it leaves empty follows `gateway.json`,
49
+ so changing that moves every collection without its own, and a collection can
50
+ go above it.
51
+
52
+ **Stand-ins.** A member the bench knows would be refused (a rate limit or quota
53
+ with no room before its seat would give up, or a connection on an alert for not
54
+ answering) stays on the collection, is named in `bench.skipped`, and an explorer
55
+ sits in for it, as the seat `stand-in for <member>`, until there is room. That is
56
+ decided per request, so it ends by itself, and stand-ins come from the same pool
57
+ as explorers: they fit the shape and are never ad hoc. A member refused for the
58
+ first time is still asked, since the bench does not know yet. With `max_explorers`
59
+ 0 there are no stand-ins: only the members ever run, and if all are held the
60
+ request gets a 429.
61
+
46
62
  **Naming:** `bench/<name>`, lower-case letters, digits and `-`. The name says
47
63
  what the collection is *for* (`reviewers`, `design`), not who is in it, because
48
64
  members change. The `bench/` prefix keeps collections apart from real model ids
@@ -60,6 +76,15 @@ tags in the message, so a caller that could not choose learns how to. A
60
76
  `bench/auto` that guessed for it was considered and rejected: the caller knows
61
77
  the task, and a guess would run the wrong models silently.
62
78
 
79
+ **Ad hoc collections.** A collection marked ad hoc (the checkbox in its dialog,
80
+ `bench collection save <name> --ad-hoc`, or `"ad_hoc": true`) runs only when a
81
+ request names it. It is left off `GET /v1/models`, `/api/tags`, `/v1beta/models`
82
+ and that refusal, so an agent choosing for itself never sees it: for the
83
+ expensive ones a person decides to run. It is not a lock, since a key that may
84
+ call it and knows its name can. To stop a client outright, limit its gateway key
85
+ to other collections. Members still sit in other collections, so to keep a
86
+ model out of explorer seats too, mark the model ad hoc as well.
87
+
63
88
  ## Prompt size
64
89
 
65
90
  The gateway estimates the prompt (system text included) at characters / 4 plus
@@ -517,7 +542,7 @@ key limited to its collection and a rate, or keep the key on the app's server.
517
542
  | Evidence | Each seat is recorded in `metrics/offloads.jsonl` as an unjudged offload when it answers; `offload.py verdict <call id>` or `POST /bench/verdict` replaces it. |
518
543
  | Collections tab | The collections, each drawn as its panel sits, and their form (one row per model, a chip per provider). Then the gateway's recent requests: every seat's status and verdict, how long the caller waited (Time), and how many verdicts are owed. Clicking a seat opens its answer and the verdict form. |
519
544
  | Catalog | `metrics/catalog.py`: context window, output limit, rate limits and capabilities per model, refreshed daily by the built-in scheduler; a free model whose price goes above zero raises a "went paid" alert. |
520
- | Discover tab | `metrics/discover.py`: FreeLLMAPI (leading) and models.dev merged into one list of models worth adding, free first, FreeLLMAPI's smartest first, marked by whether you can already reach the provider and whether the model is on the bench (on another provider counts as "also on bench"). |
545
+ | Discover tab | `metrics/discover.py`: FreeLLMAPI (leading), models.dev and each Open WebUI connection's own model list merged into one list of models worth adding, free first, FreeLLMAPI's smartest first, marked by whether you can already reach the provider (matched to a connection by API host or name) and whether the model is on the bench (on another provider counts as "also on bench"). Fetched daily with the catalog; Refresh lists fetches them now. |
521
546
  | Merged replies | Opt-in: one merger answer as `content`, every seat still in `bench` (The reply). |
522
547
  | MCP server | `mcp_server.py`: the same three routes as MCP tools for agents, `list_collections`, `ask` and `verdict`. Two transports for one `answer()`: `POST /mcp` (or `/v1/mcp`) on either port, streamable HTTP with a JSON reply per request, its tools calling the server's own routes over loopback with the caller's credential and Host; and stdio (`benchwarmers mcp`), a caller like any other over HTTP (`BENCHWARMERS_URL`, `BENCHWARMERS_KEY`). The Claude Code plugin in `plugin/` connects to `/mcp` and adds a command, a skill and a `Stop` hook (`plugin/hooks/hooks.json`, an `mcp_tool` hook) that calls `pending_verdicts` with the session's `transcript_path`: the call ids in the transcript (Claude Code keeps no `Mcp-Session-Id`, so the transcript is the record of whose panels they were), of which the call log says which are answered seats with no verdict (`POST /bench/pending`), returned once as `additionalContext`. A server on another machine cannot read the transcript, so there the hook says nothing. A tool call answers as SSE with progress notifications, which reset Claude Code's 300-second idle limit. |
523
548
 
package/PRIVACY.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Privacy policy
2
2
 
3
- *Effective 2 November 2026.*
3
+ *Effective 7 November 2026.*
4
4
 
5
5
  Benchwarmers runs on your own machine or server. It has no account, no
6
6
  telemetry and no server of its own, and nothing you do with it reaches
@@ -18,6 +18,11 @@ Wendell Research.
18
18
  model list. With an NVIDIA connection on the bench, the public file of rate
19
19
  limits build.nvidia.com states, once a day and when you press Refresh on its
20
20
  quota tile.
21
+ - **Model lists**, carrying no prompt: each Open WebUI connection's list of the
22
+ models its provider serves, asked through Open WebUI with the key it already
23
+ holds for that provider, once a day and when you press Refresh lists on
24
+ Discover. Only the models' ids, names, context sizes, prices and abilities
25
+ are kept.
21
26
  - **Sign-ins**, only when you choose one: the provider's own sign-in page, in
22
27
  your browser, and the provider's token endpoint.
23
28
  - **Quota reads**, only when you press Refresh on Quota management, carrying
package/README.md CHANGED
@@ -176,7 +176,11 @@ dependencies either way: the app is the Python standard library.
176
176
  - **Collections and seats.** A collection names the models a panel always
177
177
  includes and the shape of work it is for (review, design, enumeration...).
178
178
  Each call adds explorer seats chosen by how long a model has gone unused, so
179
- the bench keeps learning about models a router would stop asking.
179
+ the bench keeps learning about models a router would stop asking. A member
180
+ that is out of quota or rate-limited keeps its place while an explorer
181
+ stands in for it until it is back. A collection can set its own calls per
182
+ request and explorers, and one marked ad hoc (an expensive one, say) runs
183
+ only when a request names it.
180
184
  - **Verdicts.** Each seat's answer carries a call id. Once you have checked an
181
185
  answer against the real work, record whether it was useful, and which of its
182
186
  claims were real or false. Verdicts, not vibes, decide the next panel; a
@@ -203,7 +207,10 @@ dependencies either way: the app is the Python standard library.
203
207
  are read from its site and watched for changes.
204
208
  - **Discover.** FreeLLMAPI's free catalog, with models.dev for the details
205
209
  and paid models it lacks, merged into one list of models worth adding,
206
- smartest first, each with the routes this install has to it.
210
+ smartest first, each with the routes this install has to it and links to
211
+ its pages on both. Each Open WebUI connection's own model list adds what a
212
+ provider serves before either catalog lists it; **Refresh lists** fetches
213
+ them all now rather than at the daily fetch.
207
214
 
208
215
  ## Asking a panel
209
216
 
@@ -266,6 +273,7 @@ running server, so the same commands work against one on another machine.
266
273
  bench model retire <alias> --reason "..." and back: bench model restore <alias>
267
274
  bench collection save reviewers --members a,b,c --description "adversarial review"
268
275
  bench collection save reviewers --add d --remove a
276
+ bench collection save heavy-hitters --ad-hoc --max-calls 4 --max-explorers 0
269
277
  bench endpoint add openrouter --base https://openrouter.ai/api/v1 (asks for the key, hidden)
270
278
  bench key create my-app --collections core --rpm 30
271
279
  bench settings scheduler run --off perf
@@ -296,8 +304,9 @@ no server at `BENCHWARMERS_URL`.
296
304
 
297
305
  A running Benchwarmers is an MCP server at `/mcp` on its own port
298
306
  (`http://127.0.0.1:8122/mcp`; `/v1/mcp` works too), over streamable HTTP,
299
- with three tools: `list_collections`, `ask` (a collection, a prompt,
300
- optionally `merged`, `with` and `system`) and `verdict`. Nothing to start per
307
+ with four tools: `list_collections`, `ask` (a collection, a prompt,
308
+ optionally `merged`, `with` and `system`), `verdict` and `pending_verdicts`
309
+ (the seats still unjudged). Nothing to start per
301
310
  client: every agent session shares the one server, behind the same Host,
302
311
  Origin and gateway-key checks as `/v1`. The tool descriptions carry the
303
312
  rules: ask a collection, not a favourite model; verify before acting; name
@@ -443,6 +452,9 @@ reaches Wendell Research. What it sends, and where:
443
452
  its site's sitemap) and models.dev once a day, and OpenRouter's public
444
453
  model list. With an NVIDIA connection, the public file of rate limits
445
454
  build.nvidia.com states, once a day and on Refresh.
455
+ - **Model lists**, with no prompt: each Open WebUI connection's list of the
456
+ models its provider serves, asked through Open WebUI with the key it
457
+ already holds, once a day and on Discover's Refresh lists.
446
458
  - **Sign-ins**, only when you choose one: the provider's own sign-in page.
447
459
  - **Quota reads**, only when you press Refresh on Quota management, with no
448
460
  prompt: OpenRouter's key status, and xKiro's and Requesty's usage, each with
package/SECURITY.md CHANGED
@@ -64,7 +64,8 @@ open itself to anyone.
64
64
  Sign-ins through a provider's page store their token the same way.
65
65
  - **The network.** Your prompts go only to the endpoints you add; the probes,
66
66
  the suite and onboarding spend calls there too. Otherwise: public catalog
67
- reads (models.dev, FreeLLMAPI, OpenRouter's model list), a provider's
67
+ reads (models.dev, FreeLLMAPI, OpenRouter's model list), each Open WebUI
68
+ connection's model list (through Open WebUI, with its key), a provider's
68
69
  sign-in page when you choose one, Observable Plot from jsDelivr in the
69
70
  dashboard's page, and Hugging Face and GitHub only when you run
70
71
  `suite/rebench.py`. [PRIVACY.md](PRIVACY.md) lists them.
package/USAGE.md CHANGED
@@ -123,13 +123,17 @@ per model with its context, vision and tools, and price. A model served by
123
123
  several providers is still one row, with a chip per provider: tick the chip
124
124
  of the route you want. Ticking two is allowed, and the count says so. The
125
125
  search matches a model, provider or served name, and the select beside it
126
- shows one provider's models; ticked ones always stay in view. A seat's ×
127
- takes that member off, as unticking it does. The tags grey out the models that
126
+ shows one provider's models. The seats above the list always show who is
127
+ ticked, filtered out or not, and a seat's × takes that member off, as unticking
128
+ it does. The tags grey out the models that
128
129
  do not fit them.
129
130
 
130
131
  Each call also seats explorers, the models unused longest, up to the gateway's
131
132
  limits (Gateway settings, on the same page: how many calls a request may make,
132
- and how many of them explore).
133
+ and how many of them explore). A collection can set its own in its popup; left
134
+ empty, it follows those settings. A member out of quota stays on the collection
135
+ and an explorer sits in for it, named `stand-in for <member>`, until the quota
136
+ is back. Max explorers 0 means only the members ever run, stand-ins included.
133
137
 
134
138
  ## Connecting an app
135
139
 
@@ -228,12 +232,19 @@ are often wrong together.
228
232
  ## Discover: finding models to add
229
233
 
230
234
  **Discover** leads with FreeLLMAPI's catalog of free tiers and fills in from
231
- models.dev, which alone has prices, paid models and providers' base URLs. One
235
+ models.dev, which alone has prices, paid models and providers' base URLs.
236
+ The catalogs trail the providers by days, so it also reads each Open WebUI
237
+ connection's own model list (through Open WebUI, with the keys it already
238
+ has): a model a provider serves that neither catalog lists yet is a row of its
239
+ own, its price unknown where the provider's list does not say. All of them
240
+ are fetched daily; **Refresh lists** fetches them now. A provider is matched
241
+ to your Open WebUI connection by its API host, or by its name in the
242
+ connection's host (FreeLLMAPI's xKiro is `api.xkiro.com`). One
232
243
  list: free models first, then by reach, and within each reach FreeLLMAPI's
233
244
  smartest first (the **Rank** column), then what it does not rank, what is new
234
245
  this week first and badged. A row opens its popup: what
235
246
  the model takes and does, its limits and monthly budget, FreeLLMAPI's known
236
- quirks for it, links to its freellmapi.co page and models.dev, and every route
247
+ quirks for it, links to its own pages on freellmapi.co and models.dev, and every route
237
248
  this install has to it, each with an **Add via this**. Adding through a hub (Open WebUI, LiteLLM, CLI Proxy
238
249
  API) puts it in that hub too; adding direct registers it on its own endpoint
239
250
  only. Either way the onboarding joins the same queue, with the same
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.5.4
1
+ 0.5.6
package/cli.py CHANGED
@@ -328,7 +328,10 @@ def cmd_collection(args) -> None:
328
328
  "description": args.description if args.description is not None else old.get("description") or "",
329
329
  "tags": csv(args.tags) if args.tags is not None else old.get("tags") or [],
330
330
  "reply": args.reply or old.get("reply") or "all",
331
- "merger": args.merger if args.merger is not None else old.get("merger") or ""}
331
+ "merger": args.merger if args.merger is not None else old.get("merger") or "",
332
+ "ad_hoc": args.ad_hoc if args.ad_hoc is not None else bool(old.get("ad_hoc"))}
333
+ for key, given in (("max_calls", args.max_calls), ("max_explorers", args.max_explorers)):
334
+ body[key] = old.get(key) if given is None else None if given == "default" else given
332
335
  reply = call("POST", "/api/collection", body)
333
336
  out(args, reply, lambda r: done(r, f"saved collection {name}: {', '.join(members)}"))
334
337
 
@@ -497,6 +500,8 @@ keeps only the answers. Then judge each seat you checked:
497
500
  bench collection save reviewers --members a,b,c --description "adversarial review" --tags review
498
501
  bench collection save reviewers --add d --remove a (the rest stays as it was)
499
502
  bench collection save reviewers --reply merged --merger a
503
+ bench collection save heavy --ad-hoc (runs only when called by name; --no-ad-hoc undoes it)
504
+ bench collection save heavy --max-calls 4 --max-explorers 0 (its own limits; "default" follows the gateway's)
500
505
  bench collection show reviewers
501
506
  bench collection rm reviewers --yes""",
502
507
  "endpoint": """examples:
@@ -629,6 +634,12 @@ def parser() -> argparse.ArgumentParser:
629
634
  c.add_argument("--tags", help="comma-separated")
630
635
  c.add_argument("--reply", choices=["all", "merged", "route"])
631
636
  c.add_argument("--merger", help="the alias that merges, for --reply merged")
637
+ c.add_argument("--ad-hoc", action=argparse.BooleanOptionalAction, default=None,
638
+ help="only when called by name: left off what callers choose from")
639
+ own_limit = lambda v: v if v == "default" else int(v) # noqa: E731
640
+ c.add_argument("--max-calls", type=own_limit, help="its own max calls per request, or default for the gateway's")
641
+ c.add_argument("--max-explorers", type=own_limit,
642
+ help="its own max explorers, or default for the gateway's; 0 means only the members ever run")
632
643
 
633
644
  cmd("endpoints", cmd_endpoints, "where models are served from")
634
645
 
package/gateway.py CHANGED
@@ -333,10 +333,21 @@ def retired_why(entry: dict) -> str:
333
333
  def refused_now(aliases, est_tokens: int, within: float) -> dict[str, str]:
334
334
  """alias -> why, for models whose known rate limits or quota leave no room
335
335
  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."""
336
+ would only bring back the bench's own refusal, so they are not seated.
337
+ So are models on a connection the alerts say has stopped answering: the
338
+ daily probes still try it, and the first answer ends the alert."""
337
339
  import pacing
338
340
  out = {}
341
+ try:
342
+ import alerts
343
+ down = {m: a.get("message") or "its connection is not answering"
344
+ for a in alerts.active().values() if a.get("kind") == "connection" for m in a.get("aliases") or []}
345
+ except Exception: # noqa: BLE001 -- a broken alert file is not a refusal
346
+ down = {}
339
347
  for a in aliases:
348
+ if a in down:
349
+ out[a] = down[a]
350
+ continue
340
351
  try:
341
352
  h = pacing.held(a, route.connection_of(a), est_tokens)
342
353
  except Exception: # noqa: BLE001 -- a broken limit file is not a refusal
@@ -560,7 +571,7 @@ def run_collection(name: str, messages: list[dict], nominated: list[str] | None
560
571
  if not prompt.strip():
561
572
  raise ValueError("no user message to put to the collection")
562
573
  collection = bench_collections.load()[name]
563
- s = bench_collections.settings()
574
+ s = bench_collections.limits(collection)
564
575
  raw = collection.get("members") or []
565
576
  # The editor dedupes and caps; a hand-edited file may not have.
566
577
  members = list(dict.fromkeys(m for m in raw if isinstance(m, str) and m))
@@ -615,18 +626,22 @@ def run_collection(name: str, messages: list[dict], nominated: list[str] | None
615
626
  # like the others; it would only come back as the refusal.
616
627
  held = refused_now([*sitting, *nominated], needed, SEAT_TIMEOUT)
617
628
  skipped += [{"seat": "member" if a in sitting else "with", "alias": a, "why": why} for a, why in held.items()]
629
+ # AND AN EXPLORER SITS IN FOR A HELD MEMBER, until its limit has room: the
630
+ # member stays on the collection, and this is decided per request. Each
631
+ # one is named on its seat. Max explorers 0 means only the members ever run.
632
+ lent = [a for a in sitting if a in held] if s["max_explorers"] else []
618
633
  sitting = [a for a in sitting if a not in held]
619
634
  nominated = [a for a in nominated if a not in held]
620
635
  if pictures and not collection.get("shape"):
621
636
  shape, how = "vision", "the request carries images"
622
- elif not n_explorers:
637
+ elif not n_explorers and not lent:
623
638
  shape, how = collection.get("shape") or "general", "no explorer seats to fill"
624
639
  elif collection.get("shape"):
625
640
  shape, how = collection["shape"], "set on the collection"
626
641
  else:
627
642
  shape, how = pick_shape(whole)
628
643
  seats = [(f"member{i}", "member", alias) for i, alias in enumerate(sitting, 1)]
629
- if n_explorers:
644
+ if n_explorers or lent:
630
645
  # `with` in the request body is route.py's --with: an explorer seat.
631
646
  from run import _registered_names
632
647
  avoid = retiring() | set(too_small(needed, _registered_names())) # explorers must fit too
@@ -635,9 +650,11 @@ def run_collection(name: str, messages: list[dict], nominated: list[str] | None
635
650
  if pictures:
636
651
  avoid |= {a for a in _registered_names() if cannot_see(a)} # and see
637
652
  p = route.panel(shape, large=len(whole) > LARGE, nominated=nominated or None,
638
- members=sitting, explorers=n_explorers, avoid=avoid)
653
+ members=sitting, explorers=n_explorers, avoid=avoid, stand_ins=len(lent))
639
654
  seats += [(key, label, p[key]["model"]) for key, label in
640
655
  (("explorer", "explorer"), ("explorer2", "explorer 2")) if p.get(key)]
656
+ seats += [(f"standin{i}", f"stand-in for {member}", r["model"])
657
+ for i, (member, r) in enumerate(zip(lent, p["stand_ins"]), 1)]
641
658
  if not seats and pictures:
642
659
  raise ValueError(f"no seat in bench/{name} can see images, so none is asked rather than answer"
643
660
  " blind: " + ("; ".join(f"{k['alias']} {k['why']}" for k in skipped) or "no members")
@@ -716,16 +733,19 @@ def models() -> list[str]:
716
733
  return [bench_collections.PREFIX + n for n in bench_collections.load()]
717
734
 
718
735
 
719
- def listed(who: str) -> list[tuple[str, dict]]:
736
+ def listed(who: str, ad_hoc: bool = False) -> list[tuple[str, dict]]:
720
737
  """(model name, what it is for) for every collection key `who` may call,
721
738
  each three times: as set, and in each other reply mode, so a client that
722
739
  offers only listed models (an agent's model picker above all, for
723
740
  `:route`) can still pick any. What GET /v1/models, /api/tags and
724
- /v1beta/models list, each in its own shape."""
741
+ /v1beta/models list, each in its own shape. `ad_hoc`: with the collections
742
+ not on offer, for looking up one a client already named."""
725
743
  s, rows = bench_collections.settings(), []
726
744
  for n, c in bench_collections.load().items():
727
745
  if not guard.may_call(who, n): # a key limited to some collections sees those
728
746
  continue
747
+ if c.get("ad_hoc") and not ad_hoc: # runs when named; nobody is offered it to pick
748
+ continue
729
749
  d = bench_collections.described(n, c, s)
730
750
  rows.append((bench_collections.PREFIX + n, d))
731
751
  rows += [(f"{bench_collections.PREFIX}{n}:{other}", {**d, "reply": other})
@@ -1098,7 +1118,7 @@ class Handler(BaseHTTPRequestHandler):
1098
1118
  return self._json(200, formats.gemini_models(listed(self.who)))
1099
1119
  if path.startswith("/v1beta/models/"):
1100
1120
  name = urllib.parse.unquote(path[len("/v1beta/models/"):])
1101
- row = dict(listed(self.who)).get(name)
1121
+ row = dict(listed(self.who, ad_hoc=True)).get(name)
1102
1122
  return self._json(200, formats.gemini_model(name, row)) if row else \
1103
1123
  self._error(404, f"{name!r} is not a collection here; GET /v1beta/models lists them", fmt=fmt)
1104
1124
  if guard.mcp_route(path): # no stream from the server: every reply is a POST's
@@ -1243,7 +1263,7 @@ class Handler(BaseHTTPRequestHandler):
1243
1263
  def _show(self, body: dict) -> None:
1244
1264
  """POST /api/show: Ollama's model card, for a collection the key may call."""
1245
1265
  name = formats.ollama_model(body.get("model") or body.get("name") or "")
1246
- rows = dict(listed(self.who))
1266
+ rows = dict(listed(self.who, ad_hoc=True))
1247
1267
  row = rows.get(name) or rows.get(bench_collections.PREFIX + str(name))
1248
1268
  if not row:
1249
1269
  return self._error(404, f"model {name!r} not found; GET /api/tags lists the collections",
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
 
@@ -11,6 +11,12 @@ the same collection is called every day:
11
11
 
12
12
  explorers = min(max_explorers, max_calls - members)
13
13
 
14
+ A collection may hold its own `max_calls` and `max_explorers`; what it leaves
15
+ out follows gateway.json, so changing that moves every collection without its
16
+ own. A member out of quota keeps its place and an explorer sits in for it
17
+ until the quota is back (gateway.py), unless max_explorers is 0: then only the
18
+ members ever run.
19
+
14
20
  `shape` (review, design, …) decides which models may fill those explorer
15
21
  seats; empty means a decision model labels each prompt, or `general` without
16
22
  one. `reply` is what the caller reads: `all` (every answer, the default) or
@@ -19,7 +25,9 @@ verdict record, gateway.py) or `route` (no panel: one member, picked by its
19
25
  record, gets the request as sent, tools and all, for agent clients;
20
26
  GATEWAY.md "Route mode"). A request may override it with a `:merged`,
21
27
  `:all` or `:route` suffix on the model name, or `"bench": {"reply": ...}`
22
- in the body.
28
+ in the body. `ad_hoc` (false unless set) keeps a collection out of everything
29
+ that offers collections to choose from: it runs when a request names it, as an
30
+ ad hoc model does (suite/run.py), for the expensive ones a person decides on.
23
31
  Named `bench_collections` because `collections` would shadow the standard
24
32
  library for everything importing from metrics/.
25
33
  """
@@ -35,7 +43,8 @@ from paths import DATA # noqa: E402 -- the install's data folder (metrics/path
35
43
  FILE = DATA / "config" / "collections.json"
36
44
  SETTINGS_FILE = DATA / "config" / "gateway.json"
37
45
  DEFAULTS = {"max_calls": 5, "max_explorers": 2}
38
- NAME = re.compile(r"^[a-z0-9][a-z0-9-]{1,31}$")
46
+ BOUNDS = {"max_calls": (1, 10), "max_explorers": (0, 2)} # route.py seats at most two explorers
47
+ NAME =re.compile(r"^[a-z0-9][a-z0-9-]{1,31}$")
39
48
  PREFIX = "bench/"
40
49
  REPLIES = ("all", "merged", "route")
41
50
 
@@ -67,6 +76,19 @@ def settings() -> dict:
67
76
  return {**DEFAULTS, **{k: v for k, v in _read(SETTINGS_FILE).items() if k in DEFAULTS}}
68
77
 
69
78
 
79
+ def limit_ok(key: str, value) -> bool:
80
+ return isinstance(value, int) and not isinstance(value, bool) and BOUNDS[key][0] <= value <= BOUNDS[key][1]
81
+
82
+
83
+ def own_limits(c: dict) -> dict:
84
+ """The limits a collection sets for itself; a hand-edited one out of bounds counts as unset."""
85
+ return {k: c[k] for k in DEFAULTS if limit_ok(k, c.get(k))}
86
+
87
+
88
+ def limits(c: dict, s: dict | None = None) -> dict:
89
+ return {**(s or settings()), **own_limits(c)}
90
+
91
+
70
92
  def explorers_for(members: int, s: dict | None = None) -> int:
71
93
  s = s or settings()
72
94
  return max(0, min(s["max_explorers"], s["max_calls"] - members))
@@ -81,8 +103,8 @@ def described(name: str, c: dict, s: dict | None = None) -> dict:
81
103
  members = c.get("members", [])
82
104
  out = {"description": c.get("description") or c.get("note", ""),
83
105
  "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", "")}
106
+ "explorers": explorers_for(len(members), limits(c, s)), **own_limits(c),
107
+ "reply": c.get("reply") or "all", "merger": c.get("merger", ""), "ad_hoc": bool(c.get("ad_hoc"))}
86
108
  # What the members can handle together (metrics/catalog.py): the smallest
87
109
  # context window is what the gateway refuses above.
88
110
  try:
@@ -122,11 +144,11 @@ def reply_errors(reply: str, merger: str, known) -> list[str]:
122
144
 
123
145
  def choose_message(model: str | None) -> str:
124
146
  """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."""
147
+ every collection on offer with its description and tags, so the caller can choose."""
126
148
  asked = f"{model!r} is not a collection here." if model else "No collection was named."
127
149
  rows = [f"- {PREFIX}{n}: {c.get('description') or 'no description'}"
128
150
  + (f" [{', '.join(c.get('tags', []))}]" if c.get("tags") else "")
129
- for n, c in sorted(load().items())]
151
+ for n, c in sorted(load().items()) if not c.get("ad_hoc")]
130
152
  return (f"{asked} Set \"model\" to the collection that fits the task"
131
153
  " (GET /v1/models lists them with description and tags):\n"
132
154
  + ("\n".join(rows) or "- none yet: create one in the dashboard's Collections dialog"))
@@ -136,6 +158,9 @@ if __name__ == "__main__":
136
158
  # DEFAULTS, not settings(): the install's gateway.json must not decide the check.
137
159
  assert [explorers_for(n, DEFAULTS) for n in range(1, 7)] == [2, 2, 2, 1, 0, 0]
138
160
  assert explorers_for(3, {"max_calls": 4, "max_explorers": 2}) == 1
161
+ assert limits({"max_calls": 4, "max_explorers": 0}, DEFAULTS) == {"max_calls": 4, "max_explorers": 0}
162
+ assert limits({"max_calls": 99, "max_explorers": True}, DEFAULTS) == DEFAULTS and limits({}, DEFAULTS) == DEFAULTS
163
+ assert explorers_for(4, limits({"max_calls": 4}, DEFAULTS)) == 0
139
164
  assert resolve("bench/") is None and resolve("bench/no-such") is None and resolve("") is None
140
165
  assert split_reply("bench/core:merged") == ("bench/core", "merged") and split_reply("core:all") == ("core", "all")
141
166
  assert split_reply("bench/core:route") == ("bench/core", "route")
@@ -173,14 +173,11 @@ def _watch_price(alias: str, served: str, conn: str, old: dict | None, new: dict
173
173
 
174
174
  def refresh() -> dict:
175
175
  """Rebuild config/model-catalog.json for every registered chat model."""
176
+ import discover
176
177
  import endpoints
177
178
  import sources
178
179
  from run import _registered_names
179
- for name in sources.FEEDS:
180
- try:
181
- sources.fetch(name) # at most once a day; cached copy otherwise
182
- except Exception: # noqa: BLE001
183
- pass
180
+ discover.refresh() # the catalogs and providers' lists, at most once a day
184
181
  before = catalog()
185
182
  registered = _registered_names() # alias -> served name
186
183
  try: