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