@wendellresearch-ai/benchwarmers 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/GATEWAY.md +9 -5
- package/PRIVACY.md +16 -3
- package/README.md +20 -8
- package/USAGE.md +105 -14
- package/VERSION +1 -1
- package/cli.py +13 -4
- package/gateway.py +34 -2
- package/launcher.py +2 -1
- package/metrics/catalog.py +5 -0
- package/metrics/discover.py +68 -18
- package/metrics/learn.py +25 -1
- package/metrics/pacing.py +23 -3
- package/metrics/quota.py +41 -4
- package/metrics/quotaview.py +792 -0
- package/metrics/scheduler.py +2 -1
- package/metrics/sources.py +29 -6
- package/package.json +1 -1
- package/scripts/check.py +1 -0
- package/scripts/test_hubs.py +10 -5
- package/scripts/test_no_owui.py +9 -0
- package/scripts/test_quota.py +12 -2
- package/scripts/test_serve_edit.py +64 -2
- package/serve.py +311 -102
- package/ui/bench.css +92 -6
- package/ui/bench.js +544 -40
package/GATEWAY.md
CHANGED
|
@@ -389,7 +389,7 @@ the panels' verdicts. A verdict on a conversation is optional
|
|
|
389
389
|
(`POST /bench/verdict` with that call id); the offload alone already counts
|
|
390
390
|
toward rotation. The Gateway card lists route conversations in their own
|
|
391
391
|
table (when, who asked, the first message, who answered with ✓/✗, turns,
|
|
392
|
-
tool calls, failovers) and none of them
|
|
392
|
+
tool calls, failovers) and none of them counts as a verdict owed.
|
|
393
393
|
|
|
394
394
|
**Shadows (later, opt-in).** A shadow seat would answer the same turn in the
|
|
395
395
|
background for evaluation only, after the caller has its answer. The place
|
|
@@ -515,9 +515,9 @@ key limited to its collection and a rate, or keep the key on the app's server.
|
|
|
515
515
|
| Shape picker | Optional decision model (above). |
|
|
516
516
|
| Call log | `runs/gateway/calls.jsonl`, gitignored: every seat's answer, status, timing and call id. |
|
|
517
517
|
| 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
|
-
| Collections tab | The collections and their form,
|
|
519
|
-
| Catalog | `metrics/catalog.py`: context window, output limit, rate limits and capabilities per model, refreshed daily
|
|
520
|
-
| Discover tab | `metrics/discover.py`: models.dev
|
|
518
|
+
| 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
|
+
| 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"). |
|
|
521
521
|
| Merged replies | Opt-in: one merger answer as `content`, every seat still in `bench` (The reply). |
|
|
522
522
|
| 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
523
|
|
|
@@ -529,7 +529,11 @@ key limited to its collection and a rate, or keep the key on the app's server.
|
|
|
529
529
|
- **Every seat is named** in the response, including ones that failed or came
|
|
530
530
|
back empty, because who answered is itself evidence about the roster.
|
|
531
531
|
- **Free models only**, so rate limits are the constraint; a failed seat is
|
|
532
|
-
reported and logged, not retried into the limit.
|
|
532
|
+
reported and logged, not retried into the limit. A model the bench already
|
|
533
|
+
knows has no room (a limit or quota it has counted, `metrics/pacing.py`) is
|
|
534
|
+
not seated at all: an explorer seat goes to a model with room now, a member
|
|
535
|
+
is named as not asked when its wait would outlast the seat, and a request
|
|
536
|
+
with no seat left gets a 429.
|
|
533
537
|
- **Privacy:** prompts go only to providers the bench already uses.
|
|
534
538
|
- **Bind to 127.0.0.1**, as the dashboard does. Elsewhere only with an admin
|
|
535
539
|
token set, and then every call carries a gateway key made on the
|
package/PRIVACY.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Privacy policy
|
|
2
2
|
|
|
3
|
-
*Effective
|
|
3
|
+
*Effective 2 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
|
|
@@ -13,10 +13,23 @@ Wendell Research.
|
|
|
13
13
|
That includes what the gateway relays for your apps, and the calls the
|
|
14
14
|
probes, the suite and onboarding make. Each endpoint's own privacy policy
|
|
15
15
|
applies to what it receives; some free tiers keep prompts or train on them.
|
|
16
|
-
- **Catalog reads**, carrying nothing of yours:
|
|
17
|
-
(at most once a day) and OpenRouter's public
|
|
16
|
+
- **Catalog reads**, carrying nothing of yours: FreeLLMAPI (its feed and its
|
|
17
|
+
site's sitemap) and models.dev (at most once a day) and OpenRouter's public
|
|
18
|
+
model list. With an NVIDIA connection on the bench, the public file of rate
|
|
19
|
+
limits build.nvidia.com states, once a day and when you press Refresh on its
|
|
20
|
+
quota tile.
|
|
18
21
|
- **Sign-ins**, only when you choose one: the provider's own sign-in page, in
|
|
19
22
|
your browser, and the provider's token endpoint.
|
|
23
|
+
- **Quota reads**, only when you press Refresh on Quota management, carrying
|
|
24
|
+
no prompt:
|
|
25
|
+
- OpenRouter's key status, asked with your key.
|
|
26
|
+
- xKiro's usage, asked with your key for it (from its Open WebUI connection
|
|
27
|
+
when no endpoint has one). Its reply names the account's owner; only the
|
|
28
|
+
quota figures are kept.
|
|
29
|
+
- Requesty's usage for the key and its public model list, asked the same
|
|
30
|
+
way; only the count of free-model requests is kept.
|
|
31
|
+
- For CLI Proxy API's sign-ins, through the proxy, the quota endpoint of the
|
|
32
|
+
provider each sign-in belongs to (Google's, ChatGPT's).
|
|
20
33
|
- **Charts**: the dashboard's page loads Observable Plot from jsDelivr, at a
|
|
21
34
|
pinned version, in your browser.
|
|
22
35
|
- **Real-bug task seeding**, only when you run `suite/rebench.py`: dataset rows
|
package/README.md
CHANGED
|
@@ -160,8 +160,9 @@ dependencies either way: the app is the Python standard library.
|
|
|
160
160
|
4. **Onboard a few models** from it ("Onboard a model"): tick one or several;
|
|
161
161
|
each is registered, probed and run through the suite, one at a time, with
|
|
162
162
|
the progress beside the button. **Quick** probes now and leaves the suite
|
|
163
|
-
to the scheduled passes;
|
|
164
|
-
calling the model on its own, for a metered or trial
|
|
163
|
+
to the scheduled passes; **+ ad hoc** (with Full or Quick) keeps the bench
|
|
164
|
+
from ever calling the model on its own afterwards, for a metered or trial
|
|
165
|
+
key. Tick **Vision
|
|
165
166
|
model** for one that reads images and little else: it sits only for
|
|
166
167
|
questions about images, and is tested on the vision tasks, not the code ones.
|
|
167
168
|
|
|
@@ -194,10 +195,14 @@ dependencies either way: the app is the Python standard library.
|
|
|
194
195
|
(requests a minute, day or month; tokens a minute or day). They are kept per
|
|
195
196
|
connection, since models behind one key share them, and enforced: a call
|
|
196
197
|
waits when the wait fits, or is refused before it goes out. Every call lands
|
|
197
|
-
in a monthly usage ledger with its tokens
|
|
198
|
-
left
|
|
199
|
-
|
|
200
|
-
|
|
198
|
+
in a monthly usage ledger with its tokens. Quota management has a tile per
|
|
199
|
+
provider showing what is left, when it resets and who is resting. OpenRouter,
|
|
200
|
+
xKiro, Requesty and CLI Proxy API's sign-ins are read live, at no cost to
|
|
201
|
+
quota. Nothing is taken from a provider's docs; NVIDIA's own stated figures
|
|
202
|
+
are read from its site and watched for changes.
|
|
203
|
+
- **Discover.** FreeLLMAPI's free catalog, with models.dev for the details
|
|
204
|
+
and paid models it lacks, merged into one list of models worth adding,
|
|
205
|
+
smartest first, each with the routes this install has to it.
|
|
201
206
|
|
|
202
207
|
## Asking a panel
|
|
203
208
|
|
|
@@ -248,6 +253,7 @@ without it: ask and judge, and run the bench itself. It is a client of a
|
|
|
248
253
|
running server, so the same commands work against one on another machine.
|
|
249
254
|
|
|
250
255
|
bench status what is running, and where
|
|
256
|
+
bench restart start it again on the code now on disk
|
|
251
257
|
bench collections what you can ask
|
|
252
258
|
bench ask core "Where can this return a stale value?" -f cache.py
|
|
253
259
|
git diff | bench ask core "Review this diff" a pipe is the prompt
|
|
@@ -432,9 +438,15 @@ reaches Wendell Research. What it sends, and where:
|
|
|
432
438
|
a hub such as Open WebUI, or a local runtime. That includes the probes, the
|
|
433
439
|
suite and onboarding, which spend calls on your keys. Add only endpoints you
|
|
434
440
|
are content to send that work to.
|
|
435
|
-
- **Catalog reads**, no key and nothing of yours:
|
|
436
|
-
once a day, and OpenRouter's public
|
|
441
|
+
- **Catalog reads**, no key and nothing of yours: FreeLLMAPI (its feed and
|
|
442
|
+
its site's sitemap) and models.dev once a day, and OpenRouter's public
|
|
443
|
+
model list. With an NVIDIA connection, the public file of rate limits
|
|
444
|
+
build.nvidia.com states, once a day and on Refresh.
|
|
437
445
|
- **Sign-ins**, only when you choose one: the provider's own sign-in page.
|
|
446
|
+
- **Quota reads**, only when you press Refresh on Quota management, with no
|
|
447
|
+
prompt: OpenRouter's key status, and xKiro's and Requesty's usage, each with
|
|
448
|
+
your key for it (from its Open WebUI connection when no endpoint has one). CLI Proxy
|
|
449
|
+
API asks each sign-in's own provider for its quota.
|
|
438
450
|
- **Charts**: the dashboard's browser page loads Observable Plot from jsDelivr
|
|
439
451
|
at a pinned version.
|
|
440
452
|
- **Seeding real-bug tasks**, only when you run `suite/rebench.py`: the
|
package/USAGE.md
CHANGED
|
@@ -14,6 +14,7 @@ coding agent doing it with you); the gateway's whole contract is in
|
|
|
14
14
|
- [Connecting an app](#connecting-an-app)
|
|
15
15
|
- [Recording verdicts](#recording-verdicts)
|
|
16
16
|
- [Discover: finding models to add](#discover-finding-models-to-add)
|
|
17
|
+
- [Quota management](#quota-management)
|
|
17
18
|
- [The scheduler](#the-scheduler)
|
|
18
19
|
- [Your data folder](#your-data-folder)
|
|
19
20
|
|
|
@@ -25,18 +26,25 @@ coding agent doing it with you); the gateway's whole contract is in
|
|
|
25
26
|
| --- | --- | --- |
|
|
26
27
|
| Observe | **At a glance** | Every model on the bench: whether it works right now, its role, and its connection. Click a name for its popup: role, alias, facts, options, retire. |
|
|
27
28
|
| | **Benchmarks** | How models do on the suite and on real work: bugs found against false alarms, who is confidently wrong, and speed. |
|
|
28
|
-
| | **Quota management** |
|
|
29
|
+
| | **Quota management** | A tile per provider: what is left, when it resets, and which of its models are resting. Then the week's resets, and what the bench sent. |
|
|
29
30
|
| | **Logs** | Gateway calls (who sat and how it went, never the prompt), changes made from the dashboard, the scheduler, and onboarding runs. |
|
|
30
31
|
| Gateway | **Endpoints** | Where models are served from. Add an endpoint (a provider's sign-in, or any OpenAI-compatible API key), change a key, and see what each one serves. Also your own app registrations and rate limits per connection. |
|
|
31
32
|
| | **Models** | Every model on the bench, by the connection it comes from. |
|
|
32
33
|
| | **Collections** | The named panels your apps ask for, and the gateway's settings. |
|
|
33
34
|
| | **Discover** | Models worth adding, from public catalogs, with the ways this install can reach each one. |
|
|
34
|
-
| Control | **Settings** | Theme, gateway keys, the scheduler, the model client, providers whose free models work only in their own app,
|
|
35
|
+
| Control | **Settings** | Theme, gateway keys, the scheduler, the model client, providers whose free models work only in their own app, how the install is reached (shown only), and Restart. |
|
|
35
36
|
| | **Info** | Version, Python, data folder and counts. |
|
|
36
37
|
|
|
37
38
|
"Action needed", above the pages, lists what will not fix itself, such as a
|
|
38
|
-
model about to be retired,
|
|
39
|
-
folds it away until something new
|
|
39
|
+
model about to be retired, naming each model and where it comes from, with
|
|
40
|
+
Retry, Open and Retire now beside it. Hide folds it away until something new
|
|
41
|
+
turns up; an alert whose models have all been retired goes at once.
|
|
42
|
+
|
|
43
|
+
**Restart** (Settings, Server and access), or `bench restart`, starts the
|
|
44
|
+
dashboard again on the code now on disk, after an update. It waits for
|
|
45
|
+
onboarding, a scheduled job and gateway calls in progress (`--force` cuts them
|
|
46
|
+
off), and Settings says when newer code is on disk. It needs no service
|
|
47
|
+
manager: `serve.py` restarts its own dashboard.
|
|
40
48
|
|
|
41
49
|
## Onboarding models
|
|
42
50
|
|
|
@@ -44,18 +52,22 @@ folds it away until something new turns up.
|
|
|
44
52
|
not on the bench yet. Search, and tick one or several: the ticks survive a new
|
|
45
53
|
search, so you can gather models from different connections in one go.
|
|
46
54
|
Dismiss hides one you never want offered (an image model, say); the dismissed
|
|
47
|
-
list brings it back.
|
|
55
|
+
list brings it back. Each endpoint's list is kept an hour, with its age and a
|
|
56
|
+
Refresh, and a model already queued or onboarding is not offered again.
|
|
48
57
|
|
|
49
58
|
Each model is registered, probed with four short calls, and, if it answers,
|
|
50
59
|
run through the suite. **Onboard as**, under the list, chooses how much that
|
|
51
60
|
spends:
|
|
52
61
|
|
|
53
62
|
- **Full**: the probe, then the suite now.
|
|
63
|
+
- **Full + ad hoc**: the probe and the suite now, once, so routing has scores
|
|
64
|
+
for it; after that the bench never calls the model on its own (no explorer
|
|
65
|
+
seat, no scheduled runs). A collection that lists it still calls it.
|
|
54
66
|
- **Quick**: the probe now; the scheduled suite passes fill the suite in, six
|
|
55
67
|
tasks a run. The same calls, spread out.
|
|
56
|
-
- **Quick + ad hoc**: the probe only, and
|
|
57
|
-
|
|
58
|
-
|
|
68
|
+
- **Quick + ad hoc**: the probe only, and then ad hoc as above. The one that
|
|
69
|
+
spends least, for a metered, capped or trial key; its suite stays empty,
|
|
70
|
+
since the scheduled passes leave ad hoc models alone.
|
|
59
71
|
|
|
60
72
|
**Vision model**, the box beside it, gives the model role **vision** as it is
|
|
61
73
|
registered: it sits only for requests that carry images, and its suite is the
|
|
@@ -64,7 +76,7 @@ key) rather than the text and code ones. Full runs them now, Quick through the
|
|
|
64
76
|
scheduled passes. For a model that reads images and little else; a model that
|
|
65
77
|
does everything and also takes images stays general and gets both.
|
|
66
78
|
|
|
67
|
-
`bench onboard <served> [--quick [--ad-hoc]
|
|
79
|
+
`bench onboard <served> [--quick] [--ad-hoc] [--vision]` does the same from a terminal.
|
|
68
80
|
Onboarding takes minutes and spends calls, so models run **one at a time**,
|
|
69
81
|
in a queue the dashboard keeps:
|
|
70
82
|
|
|
@@ -91,6 +103,18 @@ everything else well stays `general`.
|
|
|
91
103
|
A collection is a named panel: the models it always includes, the shape of
|
|
92
104
|
work it is for (review, design, enumeration, ...), and how its reply comes
|
|
93
105
|
back. On the **Collections** page, **New collection**, then pick the members.
|
|
106
|
+
Each collection is drawn as its panel sits: a seat per member, the explorer
|
|
107
|
+
seats dashed, and the merger after an arrow.
|
|
108
|
+
|
|
109
|
+
In the popup, the panel's seats are on top, and the member list has one row
|
|
110
|
+
per model with its context, vision and tools, and price. A model served by
|
|
111
|
+
several providers is still one row, with a chip per provider: tick the chip
|
|
112
|
+
of the route you want. Ticking two is allowed, and the count says so. The
|
|
113
|
+
search matches a model, provider or served name, and the select beside it
|
|
114
|
+
shows one provider's models; ticked ones always stay in view. A seat's ×
|
|
115
|
+
takes that member off, as unticking it does. The tags grey out the models that
|
|
116
|
+
do not fit them.
|
|
117
|
+
|
|
94
118
|
Each call also seats explorers, the models unused longest, up to the gateway's
|
|
95
119
|
limits (Gateway settings, on the same page: how many calls a request may make,
|
|
96
120
|
and how many of them explore).
|
|
@@ -191,17 +215,84 @@ are often wrong together.
|
|
|
191
215
|
|
|
192
216
|
## Discover: finding models to add
|
|
193
217
|
|
|
194
|
-
**Discover**
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
218
|
+
**Discover** leads with FreeLLMAPI's catalog of free tiers and fills in from
|
|
219
|
+
models.dev, which alone has prices, paid models and providers' base URLs. One
|
|
220
|
+
list: free models first, then by reach, and within each reach FreeLLMAPI's
|
|
221
|
+
smartest first (the **Rank** column), then what it does not rank, what is new
|
|
222
|
+
this week first and badged. A row opens its popup: what
|
|
223
|
+
the model takes and does, its limits and monthly budget, FreeLLMAPI's known
|
|
224
|
+
quirks for it, links to its freellmapi.co page and models.dev, and every route
|
|
225
|
+
this install has to it, each with an **Add via this**. Adding through a hub (Open WebUI, LiteLLM, CLI Proxy
|
|
198
226
|
API) puts it in that hub too; adding direct registers it on its own endpoint
|
|
199
|
-
only. Either way the onboarding joins the same queue
|
|
227
|
+
only. Either way the onboarding joins the same queue, with the same
|
|
228
|
+
**Onboard as** and **Vision model** choice as the Onboard dialog, asked in the
|
|
229
|
+
confirm before anything is added.
|
|
200
230
|
|
|
201
231
|
Some providers' free models work only inside their own app; Discover leaves
|
|
202
232
|
those out and keeps their paid models. Settings, **Free only in their own
|
|
203
233
|
app**, switches a provider either way or adds one.
|
|
204
234
|
|
|
235
|
+
## Quota management
|
|
236
|
+
|
|
237
|
+
One tile per provider a bench model arrives through. Each draws the
|
|
238
|
+
provider's limits at the level they apply, and names a model only where a
|
|
239
|
+
limit is that model's own:
|
|
240
|
+
- **Windows**: CLI Proxy API's sign-ins have a 5-hour and a weekly limit per
|
|
241
|
+
model group, a Codex sign-in one for the account. Other providers have the
|
|
242
|
+
windows their replies' headers state; a window past its reset is shown full.
|
|
243
|
+
- **Pool**: OpenRouter's free requests a day, shared by its models, with a bar
|
|
244
|
+
per key, against the limit OpenRouter stated in a rate-limit reply.
|
|
245
|
+
- **Caps**: a day or month cap, counted from the bench's own calls: one you set
|
|
246
|
+
on Endpoints (Rate limits per connection), else one the provider states in
|
|
247
|
+
its replies without saying what is left (a Cohere trial key's 1,000 calls a
|
|
248
|
+
month), shown but not enforced until you set it. Calls made elsewhere with
|
|
249
|
+
the same key are not in the count.
|
|
250
|
+
- **Nothing stated**: how the provider's calls went in the last 24 hours, all
|
|
251
|
+
its models together (green answered, red failed).
|
|
252
|
+
|
|
253
|
+
Nothing is taken from a provider's docs, which can change without notice; a
|
|
254
|
+
limit a provider states in a rate-limit error (UnoRouter's "allows 1
|
|
255
|
+
request(s) every 1 min" for a model) is learned and kept to. One exception,
|
|
256
|
+
watched: NVIDIA states no limit per key, so its tile reads the file
|
|
257
|
+
build.nvidia.com's own badge reads (10,000 requests a day, up to 40 a minute,
|
|
258
|
+
the same for every account) daily and on Refresh, and counts the bench's
|
|
259
|
+
requests against it. If NVIDIA changes those figures, Action needed says so
|
|
260
|
+
(Got it dismisses it); if the file moves or changes shape, Action needed says
|
|
261
|
+
that too, and the tile keeps the figures it last read. Each meter leads
|
|
262
|
+
with the percentage left, then what is left of what in the provider's own unit
|
|
263
|
+
("94% left · 189 of 200 requests · day"), with its reset on the right. Under the name, one block says where the figures come
|
|
264
|
+
from (**From**: the provider's quota endpoint or its replies' headers, exact;
|
|
265
|
+
or a stated limit counted by the bench), your per-minute caps, and anything a
|
|
266
|
+
live read reported. **Models** names the connection and how it is reached
|
|
267
|
+
(each endpoint or Open WebUI connection, with its address), then lists the
|
|
268
|
+
provider's bench models (each opens its popup), whether each is free to call
|
|
269
|
+
now, and its last 24 hours of calls.
|
|
270
|
+
|
|
271
|
+
**Refresh** picks its own way, and hovering it says which:
|
|
272
|
+
- **A free read** on OpenRouter (it asks the key what is left), on xKiro (its
|
|
273
|
+
usage endpoint: free-model tokens left today), on Requesty (its usage
|
|
274
|
+
endpoint: the key's free-model requests today, by Requesty's count; its limit
|
|
275
|
+
only once a rate-limit reply has stated it) and on CLI Proxy API's sign-ins
|
|
276
|
+
(it asks the proxy for each one's quota, as its own console does; this needs
|
|
277
|
+
CLI Proxy API on the Endpoints page as a hub, with its management key).
|
|
278
|
+
xKiro's and Requesty's keys come from their endpoint, else their Open WebUI
|
|
279
|
+
connection.
|
|
280
|
+
- **One 16-token request** where a provider states its limits only in its
|
|
281
|
+
replies' headers, to read them (Groq, Routeway, a Cohere trial key). Every
|
|
282
|
+
ordinary call reads them too, through Open WebUI as well, since the bench
|
|
283
|
+
streams.
|
|
284
|
+
- **The bench's own records** again everywhere else.
|
|
285
|
+
|
|
286
|
+
Tiles sort with the ones that are out first, then the soonest reset. Drag a
|
|
287
|
+
tile, or move it with the arrow keys, and the order is kept; **Sort** brings
|
|
288
|
+
the default order back.
|
|
289
|
+
|
|
290
|
+
Under the tiles are:
|
|
291
|
+
- the next 7 days' resets, and when each resting model is back
|
|
292
|
+
- **What the bench sent**: requests per provider this month and in the last 24
|
|
293
|
+
hours, as a chart with its table
|
|
294
|
+
- the known limits, folded away
|
|
295
|
+
|
|
205
296
|
## The scheduler
|
|
206
297
|
|
|
207
298
|
The recurring jobs (the morning availability probe, the catalog refresh, the
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.5.1
|
package/cli.py
CHANGED
|
@@ -258,9 +258,7 @@ def cmd_onboard(args) -> None:
|
|
|
258
258
|
reply = call("GET", "/api/onboard-queue") if verb == "status" else \
|
|
259
259
|
call("POST", f"/api/onboard-queue/{verb}", {})
|
|
260
260
|
else:
|
|
261
|
-
if args.
|
|
262
|
-
sys.exit("--ad-hoc goes with --quick: an ad hoc model gets no suite run of its own")
|
|
263
|
-
mode = "quick-adhoc" if args.ad_hoc else "quick" if args.quick else "full"
|
|
261
|
+
mode = ("quick" if args.quick else "full") + ("-adhoc" if args.ad_hoc else "")
|
|
264
262
|
items = [{"served": s, "endpoint": args.endpoint or "", "mode": mode, "vision": args.vision}
|
|
265
263
|
for s in args.served]
|
|
266
264
|
reply = call("POST", "/api/onboard-queue", {"items": items})
|
|
@@ -385,6 +383,13 @@ def cmd_verdict(args) -> None:
|
|
|
385
383
|
f" {len(body['real'])} real, {len(body['false'])} false"))
|
|
386
384
|
|
|
387
385
|
|
|
386
|
+
def cmd_restart(args) -> None:
|
|
387
|
+
"""serve.restart: refused while work is in progress, unless --force."""
|
|
388
|
+
reply = call("POST", "/api/restart", {"force": args.force})
|
|
389
|
+
out(args, reply, lambda r: done(r, "restarting on the code now on disk; back in a few seconds"
|
|
390
|
+
+ (f" (cut off: {'; '.join(r['cut'])})" if r.get("cut") else "")))
|
|
391
|
+
|
|
392
|
+
|
|
388
393
|
def cmd_endpoints(args) -> None:
|
|
389
394
|
reply = call("GET", "/api/endpoints")
|
|
390
395
|
out(args, reply, lambda r: table(r["endpoints"], ["name", "base", "kind", "hub", "default", "key_set", "models"],
|
|
@@ -547,6 +552,9 @@ def parser() -> argparse.ArgumentParser:
|
|
|
547
552
|
return act
|
|
548
553
|
|
|
549
554
|
cmd("status", cmd_status, "what is running, and where")
|
|
555
|
+
s = cmd("restart", cmd_restart, "start the dashboard again on the code now on disk")
|
|
556
|
+
s.add_argument("--force", action="store_true",
|
|
557
|
+
help="even while onboarding, a scheduled job or gateway calls are in progress (cuts them off)")
|
|
550
558
|
|
|
551
559
|
s = cmd("ask", cmd_ask, "ask a collection: every seat answers")
|
|
552
560
|
s.add_argument("collection", help="a collection, e.g. core (bench collections)")
|
|
@@ -598,7 +606,8 @@ def parser() -> argparse.ArgumentParser:
|
|
|
598
606
|
s.add_argument("--quick", action="store_true",
|
|
599
607
|
help="probe only; the scheduled suite passes fill the suite in later")
|
|
600
608
|
s.add_argument("--ad-hoc", action="store_true",
|
|
601
|
-
help="
|
|
609
|
+
help="the bench never calls it on its own afterwards (metered, capped or trial keys);"
|
|
610
|
+
" without --quick the suite still runs once now")
|
|
602
611
|
s.add_argument("--vision", action="store_true",
|
|
603
612
|
help="role vision: a seat only for questions about images, and its suite is the vision tasks")
|
|
604
613
|
|
package/gateway.py
CHANGED
|
@@ -330,6 +330,22 @@ def retired_why(entry: dict) -> str:
|
|
|
330
330
|
" (Restore in its model popup brings it back)")
|
|
331
331
|
|
|
332
332
|
|
|
333
|
+
def refused_now(aliases, est_tokens: int, within: float) -> dict[str, str]:
|
|
334
|
+
"""alias -> why, for models whose known rate limits or quota leave no room
|
|
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."""
|
|
337
|
+
import pacing
|
|
338
|
+
out = {}
|
|
339
|
+
for a in aliases:
|
|
340
|
+
try:
|
|
341
|
+
h = pacing.held(a, route.connection_of(a), est_tokens)
|
|
342
|
+
except Exception: # noqa: BLE001 -- a broken limit file is not a refusal
|
|
343
|
+
continue
|
|
344
|
+
if h and h[0] > within:
|
|
345
|
+
out[a] = f"held by a rate limit: {h[1]}; room in {pacing._wait_text(h[0])}"
|
|
346
|
+
return out
|
|
347
|
+
|
|
348
|
+
|
|
333
349
|
def retiring() -> set[str]:
|
|
334
350
|
"""Aliases not to spend an explorer seat on: those on a retiring alert
|
|
335
351
|
(failing their probes; 2026-10-16 both explorers of a bench/core call were
|
|
@@ -407,6 +423,10 @@ class TooLarge(ValueError):
|
|
|
407
423
|
"""The prompt does not fit a member's context window (HTTP 413)."""
|
|
408
424
|
|
|
409
425
|
|
|
426
|
+
class AllHeld(LookupError):
|
|
427
|
+
"""Every seat is held by a known rate limit (HTTP 429): try again later."""
|
|
428
|
+
|
|
429
|
+
|
|
410
430
|
def estimate_tokens(text: str) -> int:
|
|
411
431
|
"""Characters / 4 plus 10%: tokenizers differ by family, so this errs high.
|
|
412
432
|
The windows it is compared with are corrected by learned 'maximum context
|
|
@@ -590,6 +610,13 @@ def run_collection(name: str, messages: list[dict], nominated: list[str] | None
|
|
|
590
610
|
f" windows) in bench/{name}. Trim it under {min(small.values()):,} tokens,"
|
|
591
611
|
" or choose a collection whose members have larger windows"
|
|
592
612
|
" (GET /v1/models lists each collection's context_window).")
|
|
613
|
+
# A MODEL THE BENCH KNOWS WILL BE REFUSED IS NOT ASKED: a member or a
|
|
614
|
+
# `with` whose limits have no room before its seat would give up, named
|
|
615
|
+
# like the others; it would only come back as the refusal.
|
|
616
|
+
held = refused_now([*sitting, *nominated], needed, SEAT_TIMEOUT)
|
|
617
|
+
skipped += [{"seat": "member" if a in sitting else "with", "alias": a, "why": why} for a, why in held.items()]
|
|
618
|
+
sitting = [a for a in sitting if a not in held]
|
|
619
|
+
nominated = [a for a in nominated if a not in held]
|
|
593
620
|
if pictures and not collection.get("shape"):
|
|
594
621
|
shape, how = "vision", "the request carries images"
|
|
595
622
|
elif not n_explorers:
|
|
@@ -603,6 +630,8 @@ def run_collection(name: str, messages: list[dict], nominated: list[str] | None
|
|
|
603
630
|
# `with` in the request body is route.py's --with: an explorer seat.
|
|
604
631
|
from run import _registered_names
|
|
605
632
|
avoid = retiring() | set(too_small(needed, _registered_names())) # explorers must fit too
|
|
633
|
+
# and have room now: an explorer seat can go to a model without a wait.
|
|
634
|
+
avoid |= set(refused_now(_registered_names(), needed, 0))
|
|
606
635
|
if pictures:
|
|
607
636
|
avoid |= {a for a in _registered_names() if cannot_see(a)} # and see
|
|
608
637
|
p = route.panel(shape, large=len(whole) > LARGE, nominated=nominated or None,
|
|
@@ -615,8 +644,9 @@ def run_collection(name: str, messages: list[dict], nominated: list[str] | None
|
|
|
615
644
|
+ ", and no model that takes images was free for an explorer seat. Add one to the"
|
|
616
645
|
" collection, or call one whose together.capabilities on GET /v1/models lists vision.")
|
|
617
646
|
if not seats:
|
|
618
|
-
raise
|
|
619
|
-
|
|
647
|
+
raise (AllHeld if held else LookupError)(
|
|
648
|
+
f"collection {name!r} has no members and no explorer could be seated"
|
|
649
|
+
+ "".join(f"; {k['alias']} not asked: {k['why']}" for k in skipped))
|
|
620
650
|
panel_id = f"p{dt.datetime.now():%Y%m%d-%H%M%S}-{secrets.token_hex(2)}"
|
|
621
651
|
model = bench_collections.PREFIX + name
|
|
622
652
|
task = " ".join(prompt.split())[:90] + f" ({model}, {shape})"
|
|
@@ -718,6 +748,8 @@ def refusal(exc: Exception) -> tuple[int, str]:
|
|
|
718
748
|
return 413, str(exc)
|
|
719
749
|
if isinstance(exc, ValueError):
|
|
720
750
|
return 400, str(exc)
|
|
751
|
+
if isinstance(exc, AllHeld):
|
|
752
|
+
return 429, str(exc)
|
|
721
753
|
if isinstance(exc, LookupError):
|
|
722
754
|
return 503, str(exc)
|
|
723
755
|
return 500, f"the panel failed ({type(exc).__name__}: {exc})"
|
package/launcher.py
CHANGED
|
@@ -22,7 +22,7 @@ ROOT = Path(__file__).resolve().parent # the app: a checkout, th
|
|
|
22
22
|
SERVERS = {"serve": "serve.py", "gateway": "gateway.py", "mcp": "mcp_server.py",
|
|
23
23
|
"init": str(Path("scripts") / "init.py")}
|
|
24
24
|
# Everything but running a server is cli.py, a client of a running server.
|
|
25
|
-
CLIENT = ("status", "ask", "verdict", "models", "model", "candidates", "onboard", "dismiss",
|
|
25
|
+
CLIENT = ("status", "restart", "ask", "verdict", "models", "model", "candidates", "onboard", "dismiss",
|
|
26
26
|
"collections", "collection", "endpoints", "endpoint", "keys", "key", "settings")
|
|
27
27
|
|
|
28
28
|
|
|
@@ -57,6 +57,7 @@ def usage() -> str:
|
|
|
57
57
|
"",
|
|
58
58
|
"Using it (a client of a running server, BENCHWARMERS_URL; --help on any):",
|
|
59
59
|
" status what is running, and where",
|
|
60
|
+
" restart [--force] start the dashboard again on the code now on disk",
|
|
60
61
|
" ask <collection> [prompt] ask a collection; -f FILE adds a file, a pipe reads stdin",
|
|
61
62
|
" verdict <call> --useful|--not-useful [--real ...] [--false ...]",
|
|
62
63
|
" models | model <action> list, rename, role, retire, restore, max-tokens, ...",
|
package/metrics/catalog.py
CHANGED
|
@@ -283,6 +283,11 @@ def refresh() -> dict:
|
|
|
283
283
|
"refreshed": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
284
284
|
"models": models}
|
|
285
285
|
CATALOG.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
286
|
+
try: # NVIDIA's stated limits, watched daily (an alert on a change)
|
|
287
|
+
import quotaview
|
|
288
|
+
quotaview.watch_nvidia()
|
|
289
|
+
except Exception: # noqa: BLE001
|
|
290
|
+
pass
|
|
286
291
|
return data
|
|
287
292
|
|
|
288
293
|
|
package/metrics/discover.py
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
|
-
"""One list of models worth adding, from models.dev
|
|
1
|
+
"""One list of models worth adding, from FreeLLMAPI and models.dev, deduped.
|
|
2
2
|
|
|
3
3
|
python metrics/discover.py # summary: free models by reach, new this week
|
|
4
4
|
|
|
5
5
|
The shopping list: Discover's Add (serve.py) puts a model on the bench by one
|
|
6
6
|
of the routes below. A model offered by the same provider in both sources
|
|
7
|
-
appears once, carrying both sources
|
|
7
|
+
appears once, carrying both sources. FreeLLMAPI leads where it lists the
|
|
8
|
+
model (its name, its provider's name, context, tools, limits, its smartest
|
|
9
|
+
and fastest ranks, size, monthly budget, quirks and freellmapi.co page);
|
|
10
|
+
models.dev fills the rest, and alone has the paid models and prices, API base
|
|
11
|
+
URLs (reach, direct routes), modalities, release and knowledge dates.
|
|
12
|
+
Image and audio generation in FreeLLMAPI's list are left out: the bench asks
|
|
13
|
+
for text.
|
|
8
14
|
|
|
9
15
|
FREE
|
|
10
16
|
----
|
|
@@ -149,7 +155,7 @@ def hub_routes(rows: list[dict]) -> tuple[list[list[dict]], list[str]]:
|
|
|
149
155
|
mine.append({"via": "hub", "hub": which, "connection": conn, "endpoint": ep,
|
|
150
156
|
"label": f"{r['provider_name']} via {label} ({conn} connection)",
|
|
151
157
|
"note": f"Also serves it to everything else that uses {label}.",
|
|
152
|
-
"steps": ["; ".join(said), "The bench: register
|
|
158
|
+
"steps": ["; ".join(said), "The bench: register it and onboard it as chosen below"],
|
|
153
159
|
"on_bench_as": next((a for e, _c, _n, a, s in bench if e == ep and s == served), None)})
|
|
154
160
|
out.append(mine)
|
|
155
161
|
return out, errors
|
|
@@ -158,6 +164,8 @@ def hub_routes(rows: list[dict]) -> tuple[list[list[dict]], list[str]]:
|
|
|
158
164
|
def build() -> list[dict]:
|
|
159
165
|
import endpoints
|
|
160
166
|
md, fl = sources.cached("modelsdev"), sources.cached("freellmapi")
|
|
167
|
+
pages = set(sources.cached("freellmapi-pages").get("slugs") or [])
|
|
168
|
+
platform_names = {p.get("id"): p.get("name") for p in fl.get("platforms") or []}
|
|
161
169
|
md_conn = {v: k for k, v in sources.connection_to_modelsdev().items()}
|
|
162
170
|
bench = _on_bench()
|
|
163
171
|
# Open WebUI's connections, as far as the bench can tell without asking it.
|
|
@@ -185,31 +193,37 @@ def build() -> list[dict]:
|
|
|
185
193
|
"vision": "image" in ((m.get("modalities") or {}).get("input") or []),
|
|
186
194
|
"released": m.get("release_date"), "knowledge": m.get("knowledge"),
|
|
187
195
|
"open_weights": m.get("open_weights"), "api": p.get("api"),
|
|
188
|
-
"openai_like": openai_like, "connection": md_conn.get(prov), "limits": None
|
|
196
|
+
"openai_like": openai_like, "connection": md_conn.get(prov), "limits": None,
|
|
197
|
+
"rank": None, "speed": None, "size": None, "budget": None, "quirks": [], "page": None}
|
|
189
198
|
|
|
190
199
|
for m in fl.get("models") or []:
|
|
200
|
+
if (m.get("modality") or "chat") != "chat":
|
|
201
|
+
continue # image or audio generation
|
|
191
202
|
plat = m.get("platform", "")
|
|
192
203
|
prov = sources.FREELLMAPI_TO_MODELSDEV.get(plat, plat)
|
|
193
204
|
key = (prov, sources.norm_model(m.get("modelId", "")))
|
|
194
|
-
|
|
205
|
+
lead = {"sources": ["freellmapi"], "free": True, # listed in a free-tier catalog
|
|
206
|
+
"name": sources.freellmapi_name(m.get("displayName") or "") or m.get("modelId"),
|
|
207
|
+
"provider_name": platform_names.get(plat) or prov,
|
|
208
|
+
"limits": {k: v for k, v in (m.get("limits") or {}).items() if v} or None,
|
|
209
|
+
"rank": m.get("intelligenceRank"), "speed": m.get("speedRank"),
|
|
210
|
+
"size": m.get("sizeLabel") or None, "budget": m.get("monthlyTokenBudget") or None,
|
|
211
|
+
"quirks": [{k: q.get(k) for k in ("title", "body", "severity")} for q in m.get("quirks") or []],
|
|
212
|
+
"page": sources.freellmapi_page(m.get("displayName") or "", pages)}
|
|
195
213
|
row = rows.get(key)
|
|
196
214
|
if row:
|
|
197
|
-
row
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
row["context"] = row["context"] or m.get("contextWindow")
|
|
201
|
-
row["tools"] = row["tools"] if row["tools"] is not None else m.get("supportsTools")
|
|
215
|
+
row.update(lead, sources=list(dict.fromkeys(["freellmapi", *row["sources"]])),
|
|
216
|
+
context=m.get("contextWindow") or row["context"], # the free tier's, where it says
|
|
217
|
+
tools=row["tools"] if m.get("supportsTools") is None else m.get("supportsTools"))
|
|
202
218
|
else:
|
|
203
219
|
rows[key] = {
|
|
204
|
-
"in_mods": ["text", "image"] if m.get("supportsVision") else ["text"], "out_mods": ["text"],
|
|
205
|
-
"provider": prov, "
|
|
206
|
-
"
|
|
207
|
-
"free": True, "cost": None, "context": m.get("contextWindow"), "output": None,
|
|
220
|
+
**lead, "in_mods": ["text", "image"] if m.get("supportsVision") else ["text"], "out_mods": ["text"],
|
|
221
|
+
"provider": prov, "model": m.get("modelId"),
|
|
222
|
+
"cost": None, "context": m.get("contextWindow"), "output": None,
|
|
208
223
|
"tools": m.get("supportsTools"), "reasoning": None,
|
|
209
224
|
"vision": m.get("supportsVision"), "released": None, "knowledge": None,
|
|
210
225
|
"open_weights": None, "api": None,
|
|
211
|
-
"openai_like": False, "connection": FREELLMAPI_TO_CONNECTION.get(plat)
|
|
212
|
-
"limits": lim}
|
|
226
|
+
"openai_like": False, "connection": FREELLMAPI_TO_CONNECTION.get(plat)}
|
|
213
227
|
|
|
214
228
|
# The same model through ANOTHER provider is fine to add, but worth knowing.
|
|
215
229
|
by_model: dict[str, list[str]] = {}
|
|
@@ -245,7 +259,11 @@ def build() -> list[dict]:
|
|
|
245
259
|
out.append(r)
|
|
246
260
|
_write_seen(seen)
|
|
247
261
|
order = {"connected": 0, "easy to add": 1, "on bench": 2, "other": 3}
|
|
248
|
-
|
|
262
|
+
# Within each reach, FreeLLMAPI's smartest first; the models it does not rank
|
|
263
|
+
# after, new ones first. Not new first overall: models.dev adds hundreds a
|
|
264
|
+
# week, which buried the ranked ones (the badge still marks them).
|
|
265
|
+
out.sort(key=lambda r: (not r["free"], order[r["reach"]], r["rank"] is None, r["rank"] or 0, not r["new"],
|
|
266
|
+
r["provider"], r["model"]))
|
|
249
267
|
return out
|
|
250
268
|
|
|
251
269
|
|
|
@@ -293,6 +311,38 @@ def _selftest() -> None:
|
|
|
293
311
|
over = sources.free_in_own_client_only({"opencode": False, "x": True})
|
|
294
312
|
assert over["opencode"] is False and over["x"] is True and over["opencode-go"] is True, over
|
|
295
313
|
assert sources.free_in_own_client_only(True) == sources.free_in_own_client_only({}) # not a map: ignored
|
|
314
|
+
# freellmapi.co names a page by the display name without its note; only its sitemap's pages exist.
|
|
315
|
+
assert sources.freellmapi_page("Llama 4 Scout (CF)", {"llama-4-scout"}) == "https://freellmapi.co/model/llama-4-scout"
|
|
316
|
+
assert sources.freellmapi_page("GLM-4.7 FlashX", {"llama-4-scout"}) is None
|
|
317
|
+
# FreeLLMAPI leads where it lists a model, models.dev fills the rest; image models out; smartest first.
|
|
318
|
+
md = {"acme": {"name": "Acme", "api": "https://acme.example/v1", "models": {
|
|
319
|
+
"acme/big": {"name": "Big", "cost": {"input": 1, "output": 2}, "limit": {"context": 8000},
|
|
320
|
+
"modalities": {"input": ["text", "image"], "output": ["text"]}, "release_date": "2026-01-01"},
|
|
321
|
+
"acme/small": {"name": "Small", "cost": {"input": 0, "output": 0}},
|
|
322
|
+
"acme/tiny": {"name": "Tiny", "cost": {"input": 0, "output": 0}}}}}
|
|
323
|
+
fl = {"platforms": [{"id": "acme", "name": "Acme Cloud"}], "models": [
|
|
324
|
+
{"platform": "acme", "modelId": "acme/big", "displayName": "Big One (Acme)", "intelligenceRank": 3,
|
|
325
|
+
"speedRank": 9, "sizeLabel": "Large", "monthlyTokenBudget": "~3M", "contextWindow": 4000,
|
|
326
|
+
"limits": {"rpm": 10, "rpd": None}, "quirks": [{"slug": "q", "title": "T", "body": "B", "severity": "info"}]},
|
|
327
|
+
{"platform": "acme", "modelId": "acme/painter", "displayName": "Painter", "modality": "image"},
|
|
328
|
+
{"platform": "acme", "modelId": "acme/tiny", "displayName": "Tiny", "intelligenceRank": 1}]}
|
|
329
|
+
feeds = {"modelsdev": md, "freellmapi": fl, "freellmapi-pages": {"slugs": ["big-one"]}}
|
|
330
|
+
import tempfile
|
|
331
|
+
kept = sources.cached, globals()["_on_bench"], globals()["SEEN"]
|
|
332
|
+
try:
|
|
333
|
+
sources.cached = lambda name: feeds.get(name, {})
|
|
334
|
+
globals().update(_on_bench=lambda: [], SEEN=pathlib.Path(tempfile.mkdtemp()) / "seen.json")
|
|
335
|
+
rows = [r for r in build() if r["provider"] == "acme"]
|
|
336
|
+
finally:
|
|
337
|
+
sources.cached = kept[0]
|
|
338
|
+
globals().update(_on_bench=kept[1], SEEN=kept[2])
|
|
339
|
+
big = next(r for r in rows if r["model"] == "acme/big")
|
|
340
|
+
assert (big["sources"], big["name"], big["provider_name"]) == (["freellmapi", "models.dev"], "Big One", "Acme Cloud"), big
|
|
341
|
+
assert big["free"] and big["context"] == 4000 and big["limits"] == {"rpm": 10} and big["released"] == "2026-01-01", big
|
|
342
|
+
assert (big["rank"], big["speed"], big["size"], big["budget"]) == (3, 9, "Large", "~3M"), big
|
|
343
|
+
assert big["page"] == "https://freellmapi.co/model/big-one" and big["quirks"] == [
|
|
344
|
+
{"title": "T", "body": "B", "severity": "info"}] and big["vision"], big
|
|
345
|
+
assert [r["model"] for r in rows] == ["acme/tiny", "acme/big", "acme/small"], [r["model"] for r in rows]
|
|
296
346
|
print("discover selftest ok")
|
|
297
347
|
|
|
298
348
|
|
|
@@ -306,7 +356,7 @@ def main() -> None:
|
|
|
306
356
|
by = {}
|
|
307
357
|
for r in free:
|
|
308
358
|
by[r["reach"]] = by.get(r["reach"], 0) + 1
|
|
309
|
-
print(f"{len(rows)} models ({len(free)} free) from models.dev
|
|
359
|
+
print(f"{len(rows)} models ({len(free)} free) from FreeLLMAPI + models.dev;"
|
|
310
360
|
f" free by reach: {by}; new this week: {sum(r['new'] for r in rows)}")
|
|
311
361
|
both = sum(1 for r in rows if len(r["sources"]) > 1)
|
|
312
362
|
print(f"{both} listed by both sources (shown once)")
|