@wendellresearch-ai/benchwarmers 0.2.0 → 0.2.2
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 +152 -147
- package/BENCHMARKS.md +262 -262
- package/GATEWAY.md +415 -272
- package/NOTICE +28 -28
- package/PRIVACY.md +46 -46
- package/README.md +408 -314
- package/SECURITY.md +83 -83
- package/TERMS.md +22 -22
- package/USAGE.md +186 -163
- package/VERSION +1 -1
- package/bin/benchwarmers.js +16 -74
- package/cli.py +674 -0
- package/examples/config/gateway.json +5 -5
- package/examples/config/model-roles.json +11 -11
- package/gateway.py +939 -755
- package/launcher.py +120 -0
- package/mcp_server.py +266 -266
- package/metrics/alerts.py +116 -116
- package/metrics/aliases.py +41 -41
- package/metrics/bench_collections.py +141 -141
- package/metrics/catalog.py +304 -295
- package/metrics/client.py +804 -394
- package/metrics/cliproxy.py +191 -191
- package/metrics/connect.py +691 -691
- package/metrics/cost.py +171 -152
- package/metrics/decide.py +227 -227
- package/metrics/discover.py +318 -318
- package/metrics/endpoints.py +436 -436
- package/metrics/formats.py +1021 -0
- package/metrics/guard.py +647 -592
- package/metrics/learn.py +234 -221
- package/metrics/ledger.py +148 -0
- package/metrics/litellm_hub.py +206 -206
- package/metrics/model_roles.py +94 -84
- package/metrics/offload.py +166 -166
- package/metrics/owui.py +205 -205
- package/metrics/pacing.py +248 -0
- package/metrics/paths.py +60 -60
- package/metrics/quota.py +383 -0
- package/metrics/route.py +581 -579
- package/metrics/scheduler.py +275 -275
- package/metrics/sources.py +167 -167
- package/metrics/wrong.py +144 -144
- package/package.json +69 -65
- package/perf/README.md +89 -89
- package/perf/run.py +284 -261
- package/scripts/auto_retire.py +367 -367
- package/scripts/check.py +113 -106
- package/scripts/check_deprecations.py +378 -378
- package/scripts/check_themes.py +103 -103
- package/scripts/init.py +124 -124
- package/scripts/list_served.py +78 -78
- package/scripts/notice.py +78 -78
- package/scripts/onboard.py +410 -331
- package/scripts/provider_tags.py +129 -129
- package/scripts/reprefix.py +136 -136
- package/scripts/sync_config.py +105 -105
- package/scripts/test_auth.py +255 -255
- package/scripts/test_cli.py +189 -0
- package/scripts/test_client.py +277 -233
- package/scripts/test_connect.py +222 -222
- package/scripts/test_first_run.py +180 -180
- package/scripts/test_formats.py +549 -0
- package/scripts/test_hubs.py +328 -328
- package/scripts/test_launcher.py +67 -0
- package/scripts/test_llm_log.py +85 -81
- package/scripts/test_mcp.py +178 -178
- package/scripts/test_merged.py +215 -214
- package/scripts/test_no_owui.py +291 -291
- package/scripts/test_owui.py +90 -90
- package/scripts/test_quota.py +334 -0
- package/scripts/test_serve_edit.py +453 -438
- package/scripts/test_server_json.py +79 -0
- package/scripts/test_verdict.py +102 -98
- package/scripts/test_vision.py +231 -228
- package/serve.py +3972 -3764
- package/suite/mine.py +154 -154
- package/suite/pace.py +145 -145
- package/suite/run.py +1046 -1042
- package/suite/seed.py +421 -421
- package/suite/tasks/claim-alias-unbound/fixtures/onboard.py +286 -286
- package/suite/tasks/claim-error-text-string/fixtures/decide.py +187 -187
- package/suite/tasks/claim-list-body/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-member-nominated-twice/fixtures/gateway.py +284 -284
- package/suite/tasks/claim-nominee-uses-slot/fixtures/gateway.py +284 -284
- package/suite/tasks/claim-silent-exit/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-sse-hang/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-unknown-nominee-crash/fixtures/gateway.py +284 -284
- package/suite/tasks/rebench-matchms-809-bug/task.json +23 -23
- package/suite/tasks/rebench-matchms-809-control/task.json +27 -27
- package/suite/tasks/rebench-more-itertools-1028-bug/task.json +23 -23
- package/suite/tasks/rebench-more-itertools-1028-control/task.json +27 -27
- package/suite/tasks/rebench-narwhals-2864-bug/task.json +23 -23
- package/suite/tasks/rebench-narwhals-2864-control/task.json +27 -27
- package/suite/tasks/rebench-quantecon-py-769-bug/task.json +23 -23
- package/suite/tasks/rebench-quantecon-py-769-control/task.json +27 -27
- package/suite/tasks/rebench-scrapy-6867-bug/task.json +23 -23
- package/suite/tasks/rebench-scrapy-6867-control/task.json +27 -27
- package/ui/bench.css +430 -425
- package/ui/bench.js +1757 -1576
- package/ui/themes/dark.css +35 -35
- package/ui/themes/hardwood.css +52 -52
- package/ui/themes/rainforest.css +32 -32
package/metrics/catalog.py
CHANGED
|
@@ -1,295 +1,304 @@
|
|
|
1
|
-
"""What each model on the bench can take and do: context window, output limit,
|
|
2
|
-
capabilities, rate limits — with where every value came from.
|
|
3
|
-
|
|
4
|
-
python metrics/catalog.py # refresh config/model-catalog.json from the sources
|
|
5
|
-
python metrics/catalog.py <alias> # one model's merged record
|
|
6
|
-
|
|
7
|
-
THREE FILES, ONE RECORD
|
|
8
|
-
-----------------------
|
|
9
|
-
config/model-catalog.json WRITTEN BY THE REFRESH. Per alias, what the
|
|
10
|
-
sources say, and which source said it.
|
|
11
|
-
config/model-overrides.json WRITTEN BY YOU (the model popup). Per alias,
|
|
12
|
-
any field, including its own rate limits.
|
|
13
|
-
config/connection-limits.json WRITTEN BY YOU (the Endpoints dialog). Per
|
|
14
|
-
connection: rpm, rpd, tpm, tpd
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
(
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
import
|
|
40
|
-
|
|
41
|
-
import
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
return
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
"
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
data
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
1
|
+
"""What each model on the bench can take and do: context window, output limit,
|
|
2
|
+
capabilities, rate limits — with where every value came from.
|
|
3
|
+
|
|
4
|
+
python metrics/catalog.py # refresh config/model-catalog.json from the sources
|
|
5
|
+
python metrics/catalog.py <alias> # one model's merged record
|
|
6
|
+
|
|
7
|
+
THREE FILES, ONE RECORD
|
|
8
|
+
-----------------------
|
|
9
|
+
config/model-catalog.json WRITTEN BY THE REFRESH. Per alias, what the
|
|
10
|
+
sources say, and which source said it.
|
|
11
|
+
config/model-overrides.json WRITTEN BY YOU (the model popup). Per alias,
|
|
12
|
+
any field, including its own rate limits.
|
|
13
|
+
config/connection-limits.json WRITTEN BY YOU (the Endpoints dialog). Per
|
|
14
|
+
connection: rpm, rpd, tpm, tpd, and rpmo
|
|
15
|
+
(calls a calendar month).
|
|
16
|
+
|
|
17
|
+
Since Phase 2b the built-in client enforces these, a model's own limits and
|
|
18
|
+
the learned ones (metrics/pacing.py); before, they were only shown.
|
|
19
|
+
|
|
20
|
+
`info(alias)` merges them: an override beats the refresh, a model's own limits
|
|
21
|
+
beat its connection's. Rate limits are kept per CONNECTION (nvidia-api, groq,
|
|
22
|
+
openrouter…) because that is what a key's limits belong to and what models
|
|
23
|
+
behind it share; `route.py` groups by the same host.
|
|
24
|
+
|
|
25
|
+
WHERE THE FACTS COME FROM
|
|
26
|
+
-------------------------
|
|
27
|
+
- OpenRouter's public catalog (no key), matched by model id: context window,
|
|
28
|
+
max output, input modalities (image -> vision, which covers OCR), supported
|
|
29
|
+
parameters (tools, structured output, reasoning), knowledge cutoff. A model
|
|
30
|
+
served elsewhere under the same id gets OpenRouter's figures, marked as such:
|
|
31
|
+
the same weights, but a provider can cap context lower.
|
|
32
|
+
- Open WebUI's /api/models, where a connection passes metadata through
|
|
33
|
+
(Inception, Reflection do; most do not).
|
|
34
|
+
- Everything else, including rate limits, by hand: freellmapi.co/models is a
|
|
35
|
+
good place to look them up. The bench never fetches it; its data licence is
|
|
36
|
+
not stated.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import datetime as dt
|
|
42
|
+
import json
|
|
43
|
+
import pathlib
|
|
44
|
+
import sys
|
|
45
|
+
import urllib.request
|
|
46
|
+
|
|
47
|
+
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
48
|
+
from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
|
|
49
|
+
CATALOG = DATA / "config" / "model-catalog.json"
|
|
50
|
+
OVERRIDES = DATA / "config" / "model-overrides.json"
|
|
51
|
+
LIMITS = DATA / "config" / "connection-limits.json"
|
|
52
|
+
sys.path.insert(0, str(ROOT / "metrics"))
|
|
53
|
+
|
|
54
|
+
FIELDS = ("context_window", "max_output", "vision", "tools", "structured_output",
|
|
55
|
+
"reasoning", "input_modalities", "output_modalities", "knowledge_cutoff",
|
|
56
|
+
"release_date", "open_weights", "description")
|
|
57
|
+
CAPS = ("tools", "vision", "structured_output", "reasoning")
|
|
58
|
+
LIMIT_KEYS = ("rpm", "rpd", "tpm", "tpd")
|
|
59
|
+
# A connection's limits add a monthly cap: calls a calendar month (UTC), as a
|
|
60
|
+
# trial key has (Cohere's: 1,000). Per key, so not among a model's own limits.
|
|
61
|
+
CONNECTION_LIMIT_KEYS = LIMIT_KEYS + ("rpmo",)
|
|
62
|
+
LIMIT_LABELS = {"rpm": "requests per minute", "rpd": "requests per day", "tpm": "tokens per minute",
|
|
63
|
+
"tpd": "tokens per day", "rpmo": "requests per calendar month (UTC)"}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _read(path: pathlib.Path) -> dict:
|
|
67
|
+
try:
|
|
68
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
69
|
+
except (OSError, ValueError):
|
|
70
|
+
return {}
|
|
71
|
+
return data if isinstance(data, dict) else {}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def catalog() -> dict:
|
|
75
|
+
return _read(CATALOG).get("models", {})
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def overrides() -> dict:
|
|
79
|
+
return {k: v for k, v in _read(OVERRIDES).items() if not k.startswith("_")}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def connection_limits() -> dict:
|
|
83
|
+
return {k: v for k, v in _read(LIMITS).items() if not k.startswith("_")}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def connection_of(alias: str) -> str:
|
|
87
|
+
import route
|
|
88
|
+
return route.connection_of(alias)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def info(alias: str) -> dict:
|
|
92
|
+
"""The merged record: refresh, then overrides; limits from the model's own
|
|
93
|
+
override, else its connection. `sources` names where each field came from."""
|
|
94
|
+
import learn
|
|
95
|
+
auto = catalog().get(alias, {})
|
|
96
|
+
mine = overrides().get(alias, {})
|
|
97
|
+
seen = learn.learned().get(alias, {})
|
|
98
|
+
out = {k: auto.get(k) for k in FIELDS}
|
|
99
|
+
out["cost"], out["quirks"] = auto.get("cost"), auto.get("quirks")
|
|
100
|
+
sources = dict(auto.get("sources", {}))
|
|
101
|
+
# LEARNED (metrics/learn.py): a context window an error proved, if lower;
|
|
102
|
+
# a capability seen working, whatever the refresh said.
|
|
103
|
+
if (cw := (seen.get("context_window") or {}).get("value")) and cw < (out["context_window"] or 1 << 62):
|
|
104
|
+
out["context_window"], sources["context_window"] = cw, f"learned {seen['context_window']['at'][:10]}"
|
|
105
|
+
for cap, ev in (seen.get("capabilities") or {}).items():
|
|
106
|
+
if cap in CAPS and ev.get("value"):
|
|
107
|
+
out[cap], sources[cap] = True, f"probe {ev['at'][:10]}"
|
|
108
|
+
for k in FIELDS:
|
|
109
|
+
if mine.get(k) is not None:
|
|
110
|
+
out[k], sources[k] = mine[k], "you"
|
|
111
|
+
conn = connection_of(alias)
|
|
112
|
+
limits = connection_limits().get(conn, {})
|
|
113
|
+
listed = auto.get("limits") or {} # the refresh's (FreeLLMAPI) figures
|
|
114
|
+
out["limits"] = {k: (mine.get("limits") or {}).get(k, limits.get(k, listed.get(k)))
|
|
115
|
+
for k in LIMIT_KEYS}
|
|
116
|
+
if mine.get("limits"):
|
|
117
|
+
sources["limits"] = "you (this model)"
|
|
118
|
+
elif any(limits.get(k) for k in LIMIT_KEYS):
|
|
119
|
+
sources["limits"] = f"you ({conn})"
|
|
120
|
+
for k, ev in (seen.get("limits") or {}).items():
|
|
121
|
+
if k in LIMIT_KEYS and not (mine.get("limits") or {}).get(k) and \
|
|
122
|
+
ev["value"] < (out["limits"].get(k) or 1 << 62):
|
|
123
|
+
out["limits"][k] = ev["value"]
|
|
124
|
+
sources[f"limits.{k}"] = f"learned {ev['at'][:10]}"
|
|
125
|
+
if seen.get("served_as"):
|
|
126
|
+
out["served_as"] = seen["served_as"]
|
|
127
|
+
out.update(connection=conn, sources=sources, refreshed=_read(CATALOG).get("refreshed"))
|
|
128
|
+
return out
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def consolidate(aliases: list[str]) -> dict:
|
|
132
|
+
"""What a group of models can do together: the smallest context window
|
|
133
|
+
(the real limit for the group), capabilities every member has, distinct
|
|
134
|
+
connections (members behind one share its rate limit), tightest limits."""
|
|
135
|
+
rows = {a: info(a) for a in aliases}
|
|
136
|
+
known = [r["context_window"] for r in rows.values() if r.get("context_window")]
|
|
137
|
+
tight = {}
|
|
138
|
+
for k in LIMIT_KEYS:
|
|
139
|
+
vals = [r["limits"][k] for r in rows.values() if r["limits"].get(k)]
|
|
140
|
+
tight[k] = min(vals) if vals else None
|
|
141
|
+
return {"context_window": min(known) if known else None,
|
|
142
|
+
"context_unknown": [a for a, r in rows.items() if not r.get("context_window")],
|
|
143
|
+
"capabilities": [c for c in CAPS if rows and all(r.get(c) for r in rows.values())],
|
|
144
|
+
"connections": sorted({r["connection"] for r in rows.values()}),
|
|
145
|
+
"limits": tight}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _fetch(url: str, headers: dict | None = None) -> dict:
|
|
149
|
+
req = urllib.request.Request(url, headers=headers or {})
|
|
150
|
+
with urllib.request.urlopen(req, timeout=60) as r:
|
|
151
|
+
return json.load(r)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _watch_price(alias: str, served: str, conn: str, old: dict | None, new: dict | None) -> None:
|
|
155
|
+
"""A free model that now has a price: raise it, before calls start failing.
|
|
156
|
+
models.dev can lag, so this is an early warning, not proof; the bench's own
|
|
157
|
+
'paywalled' errors still decide."""
|
|
158
|
+
import alerts
|
|
159
|
+
import endpoints
|
|
160
|
+
import sources
|
|
161
|
+
# Paid by subscription (config/connections.json `paid`): a price there is expected.
|
|
162
|
+
if conn in (endpoints.connections().get("paid") or ()):
|
|
163
|
+
return
|
|
164
|
+
key = f"model:paid:{served}"
|
|
165
|
+
if sources.is_free(old) and sources.is_free(new) is False:
|
|
166
|
+
alerts.raise_alert(key, "went paid",
|
|
167
|
+
f"{alias} is listed with a price now (models.dev): input"
|
|
168
|
+
f" {new.get('input')}, output {new.get('output')} per million tokens",
|
|
169
|
+
"check the provider; retire it or re-point it at a free slug", alias=alias)
|
|
170
|
+
elif sources.is_free(new):
|
|
171
|
+
alerts.clear(key)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def refresh() -> dict:
|
|
175
|
+
"""Rebuild config/model-catalog.json for every registered chat model."""
|
|
176
|
+
import endpoints
|
|
177
|
+
import sources
|
|
178
|
+
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
|
|
184
|
+
before = catalog()
|
|
185
|
+
registered = _registered_names() # alias -> served name
|
|
186
|
+
try:
|
|
187
|
+
orm = {m["id"]: m for m in _fetch("https://openrouter.ai/api/v1/models")["data"]}
|
|
188
|
+
except Exception as exc: # noqa: BLE001
|
|
189
|
+
sys.exit(f"cannot read OpenRouter's catalog: {exc}")
|
|
190
|
+
# Each endpoint's listing kept apart, and a model read ONLY from the one it
|
|
191
|
+
# is registered on: the direct openrouter endpoint lists qwen/qwen3.8-27b
|
|
192
|
+
# too, and pooling the listings filed OpenRouter's 1M window as groq's.
|
|
193
|
+
listings: dict[str, dict] = {}
|
|
194
|
+
for name in endpoints.load():
|
|
195
|
+
try:
|
|
196
|
+
listings[name] = endpoints.models(name)
|
|
197
|
+
except Exception: # noqa: BLE001
|
|
198
|
+
listings[name] = {}
|
|
199
|
+
home = {r["alias"]: r["endpoint"] for r in endpoints.registry()}
|
|
200
|
+
models = {}
|
|
201
|
+
for alias, served in registered.items():
|
|
202
|
+
rec, src = {}, {}
|
|
203
|
+
ep = home.get(alias, endpoints.default())
|
|
204
|
+
entry = listings.get(ep, {}).get(served, {})
|
|
205
|
+
slug = (entry.get("openrouter") or {}).get("slug")
|
|
206
|
+
o = orm.get(served) or orm.get(slug or "") or orm.get(served.removesuffix(":free"))
|
|
207
|
+
if o:
|
|
208
|
+
via = "openrouter" if connection_of(alias) == "openrouter" else "openrouter (same model id)"
|
|
209
|
+
arch, params = o.get("architecture") or {}, set(o.get("supported_parameters") or [])
|
|
210
|
+
rec.update(context_window=o.get("context_length"),
|
|
211
|
+
max_output=(o.get("top_provider") or {}).get("max_completion_tokens"),
|
|
212
|
+
input_modalities=arch.get("input_modalities"),
|
|
213
|
+
output_modalities=arch.get("output_modalities"),
|
|
214
|
+
vision="image" in (arch.get("input_modalities") or []),
|
|
215
|
+
tools="tools" in params,
|
|
216
|
+
structured_output=bool(params & {"response_format", "structured_outputs"}),
|
|
217
|
+
reasoning=bool(params & {"reasoning", "include_reasoning"}),
|
|
218
|
+
knowledge_cutoff=o.get("knowledge_cutoff"),
|
|
219
|
+
description=(o.get("description") or "")[:240] or None)
|
|
220
|
+
src.update({k: via for k, v in rec.items() if v is not None})
|
|
221
|
+
# models.dev's entry FOR THE PROVIDER THAT SERVES IT beats an
|
|
222
|
+
# OpenRouter same-id match: qwen3.8-27b on groq is capped at 131,042
|
|
223
|
+
# tokens, which OpenRouter's 1,000,000 hid. Any-provider matches only
|
|
224
|
+
# fill gaps.
|
|
225
|
+
conn = connection_of(alias)
|
|
226
|
+
md_prov = sources.connection_to_modelsdev().get(conn)
|
|
227
|
+
md = sources.modelsdev_entry(md_prov, served) if md_prov else None
|
|
228
|
+
md_src = f"models.dev {md_prov}"
|
|
229
|
+
if md is None and (hit := sources.modelsdev_anywhere(served)):
|
|
230
|
+
md_prov, md = hit
|
|
231
|
+
md_src = f"models.dev {md_prov} (same model)"
|
|
232
|
+
if md:
|
|
233
|
+
mods = (md.get("modalities") or {}).get("input")
|
|
234
|
+
vals = {"context_window": (md.get("limit") or {}).get("context"),
|
|
235
|
+
"max_output": (md.get("limit") or {}).get("output"),
|
|
236
|
+
"tools": md.get("tool_call"), "reasoning": md.get("reasoning"),
|
|
237
|
+
"input_modalities": mods, "vision": ("image" in mods) if mods else None,
|
|
238
|
+
"output_modalities": (md.get("modalities") or {}).get("output"),
|
|
239
|
+
"knowledge_cutoff": md.get("knowledge"), "release_date": md.get("release_date"),
|
|
240
|
+
"open_weights": md.get("open_weights")}
|
|
241
|
+
exact = "(same model)" not in md_src
|
|
242
|
+
for k, v in vals.items():
|
|
243
|
+
if v is None:
|
|
244
|
+
continue
|
|
245
|
+
if rec.get(k) is None or (exact and src.get(k, "").endswith("(same model id)")):
|
|
246
|
+
rec[k], src[k] = v, md_src
|
|
247
|
+
if exact and md.get("cost"):
|
|
248
|
+
rec["cost"], src["cost"] = md["cost"], md_src
|
|
249
|
+
# FreeLLMAPI's free catalog: rate limits (models.dev has none), and a
|
|
250
|
+
# context window only where nothing else gave one.
|
|
251
|
+
fl = sources.freellmapi_entry(served, conn if conn in ("groq", "mistral", "nvidia", "openrouter") else None) \
|
|
252
|
+
or sources.freellmapi_entry(served)
|
|
253
|
+
if fl:
|
|
254
|
+
lim = {k: v for k, v in (fl.get("limits") or {}).items() if v}
|
|
255
|
+
if lim:
|
|
256
|
+
rec["limits"], src["limits"] = lim, f"freellmapi {fl.get('platform')}"
|
|
257
|
+
if rec.get("context_window") is None and fl.get("contextWindow"):
|
|
258
|
+
rec["context_window"], src["context_window"] = fl["contextWindow"], f"freellmapi {fl.get('platform')}"
|
|
259
|
+
elif fl.get("contextWindow") and fl["contextWindow"] != rec.get("context_window"):
|
|
260
|
+
src["context_window_note"] = f"freellmapi says {fl['contextWindow']:,}"
|
|
261
|
+
if fl.get("quirks"):
|
|
262
|
+
rec["quirks"] = [q.get("title") for q in fl["quirks"] if q.get("title")][:5]
|
|
263
|
+
# The serving connection's own figures, where it passes them, beat a
|
|
264
|
+
# same-id OpenRouter match: they describe this provider's limits.
|
|
265
|
+
feats = set(entry.get("supported_features") or [])
|
|
266
|
+
own = {"context_window": entry.get("context_length"),
|
|
267
|
+
"max_output": entry.get("max_output_length"),
|
|
268
|
+
"input_modalities": entry.get("input_modalities"),
|
|
269
|
+
"output_modalities": entry.get("output_modalities")}
|
|
270
|
+
if entry.get("supported_features") is not None:
|
|
271
|
+
own.update(tools="tools" in feats,
|
|
272
|
+
structured_output=bool(feats & {"json_mode", "structured_outputs"}))
|
|
273
|
+
if entry.get("input_modalities") is not None:
|
|
274
|
+
own["vision"] = "image" in entry["input_modalities"]
|
|
275
|
+
for k, v in own.items():
|
|
276
|
+
if v is not None:
|
|
277
|
+
rec[k], src[k] = v, f"{ep or 'endpoint'} listing"
|
|
278
|
+
rec["sources"] = src
|
|
279
|
+
models[alias] = rec
|
|
280
|
+
_watch_price(alias, served, conn, before.get(alias, {}).get("cost"), rec.get("cost"))
|
|
281
|
+
data = {"_why": "Written by `python metrics/catalog.py`; edit config/model-overrides.json"
|
|
282
|
+
" (the model popup) instead, which wins.",
|
|
283
|
+
"refreshed": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
284
|
+
"models": models}
|
|
285
|
+
CATALOG.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
286
|
+
return data
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def main() -> None:
|
|
290
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
291
|
+
sys.path.insert(0, str(ROOT / "suite"))
|
|
292
|
+
if len(sys.argv) > 1:
|
|
293
|
+
print(json.dumps(info(sys.argv[1]), indent=2, ensure_ascii=False))
|
|
294
|
+
return
|
|
295
|
+
data = refresh()["models"]
|
|
296
|
+
known = sum(1 for r in data.values() if r.get("context_window"))
|
|
297
|
+
print(f"{len(data)} models; context window known for {known}. Fill the rest in the model popup.")
|
|
298
|
+
for alias, r in sorted(data.items()):
|
|
299
|
+
caps = ", ".join(c for c in CAPS if r.get(c)) or "-"
|
|
300
|
+
print(f" {alias:<34} ctx {r.get('context_window') or '?':>8} {caps}")
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
if __name__ == "__main__":
|
|
304
|
+
main()
|