@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 +4 -1
- package/GATEWAY.md +26 -1
- package/PRIVACY.md +6 -1
- package/README.md +16 -4
- package/SECURITY.md +2 -1
- package/USAGE.md +16 -5
- package/VERSION +1 -1
- package/cli.py +12 -1
- package/gateway.py +29 -9
- package/mcp_server.py +2 -1
- package/metrics/alerts.py +25 -0
- package/metrics/bench_collections.py +31 -6
- package/metrics/catalog.py +2 -5
- package/metrics/discover.py +191 -11
- package/metrics/owui.py +26 -0
- package/metrics/route.py +10 -3
- package/metrics/sources.py +15 -2
- package/package.json +1 -1
- package/scripts/test_formats.py +61 -1
- package/scripts/test_serve_edit.py +38 -0
- package/serve.py +116 -55
- package/suite/run.py +5 -3
- package/ui/bench.css +28 -32
- package/ui/bench.js +39 -13
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, ...).
|
|
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)
|
|
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
|
|
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
|
|
300
|
-
optionally `merged`, `with` and `system`)
|
|
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),
|
|
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
|
|
127
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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")
|
package/metrics/catalog.py
CHANGED
|
@@ -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
|
-
|
|
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:
|