@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.
Files changed (103) hide show
  1. package/AGENTS.md +152 -147
  2. package/BENCHMARKS.md +262 -262
  3. package/GATEWAY.md +415 -272
  4. package/NOTICE +28 -28
  5. package/PRIVACY.md +46 -46
  6. package/README.md +408 -314
  7. package/SECURITY.md +83 -83
  8. package/TERMS.md +22 -22
  9. package/USAGE.md +186 -163
  10. package/VERSION +1 -1
  11. package/bin/benchwarmers.js +16 -74
  12. package/cli.py +674 -0
  13. package/examples/config/gateway.json +5 -5
  14. package/examples/config/model-roles.json +11 -11
  15. package/gateway.py +939 -755
  16. package/launcher.py +120 -0
  17. package/mcp_server.py +266 -266
  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 +304 -295
  22. package/metrics/client.py +804 -394
  23. package/metrics/cliproxy.py +191 -191
  24. package/metrics/connect.py +691 -691
  25. package/metrics/cost.py +171 -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/formats.py +1021 -0
  30. package/metrics/guard.py +647 -592
  31. package/metrics/learn.py +234 -221
  32. package/metrics/ledger.py +148 -0
  33. package/metrics/litellm_hub.py +206 -206
  34. package/metrics/model_roles.py +94 -84
  35. package/metrics/offload.py +166 -166
  36. package/metrics/owui.py +205 -205
  37. package/metrics/pacing.py +248 -0
  38. package/metrics/paths.py +60 -60
  39. package/metrics/quota.py +383 -0
  40. package/metrics/route.py +581 -579
  41. package/metrics/scheduler.py +275 -275
  42. package/metrics/sources.py +167 -167
  43. package/metrics/wrong.py +144 -144
  44. package/package.json +69 -65
  45. package/perf/README.md +89 -89
  46. package/perf/run.py +284 -261
  47. package/scripts/auto_retire.py +367 -367
  48. package/scripts/check.py +113 -106
  49. package/scripts/check_deprecations.py +378 -378
  50. package/scripts/check_themes.py +103 -103
  51. package/scripts/init.py +124 -124
  52. package/scripts/list_served.py +78 -78
  53. package/scripts/notice.py +78 -78
  54. package/scripts/onboard.py +410 -331
  55. package/scripts/provider_tags.py +129 -129
  56. package/scripts/reprefix.py +136 -136
  57. package/scripts/sync_config.py +105 -105
  58. package/scripts/test_auth.py +255 -255
  59. package/scripts/test_cli.py +189 -0
  60. package/scripts/test_client.py +277 -233
  61. package/scripts/test_connect.py +222 -222
  62. package/scripts/test_first_run.py +180 -180
  63. package/scripts/test_formats.py +549 -0
  64. package/scripts/test_hubs.py +328 -328
  65. package/scripts/test_launcher.py +67 -0
  66. package/scripts/test_llm_log.py +85 -81
  67. package/scripts/test_mcp.py +178 -178
  68. package/scripts/test_merged.py +215 -214
  69. package/scripts/test_no_owui.py +291 -291
  70. package/scripts/test_owui.py +90 -90
  71. package/scripts/test_quota.py +334 -0
  72. package/scripts/test_serve_edit.py +453 -438
  73. package/scripts/test_server_json.py +79 -0
  74. package/scripts/test_verdict.py +102 -98
  75. package/scripts/test_vision.py +231 -228
  76. package/serve.py +3972 -3764
  77. package/suite/mine.py +154 -154
  78. package/suite/pace.py +145 -145
  79. package/suite/run.py +1046 -1042
  80. package/suite/seed.py +421 -421
  81. package/suite/tasks/claim-alias-unbound/fixtures/onboard.py +286 -286
  82. package/suite/tasks/claim-error-text-string/fixtures/decide.py +187 -187
  83. package/suite/tasks/claim-list-body/fixtures/gateway.py +235 -235
  84. package/suite/tasks/claim-member-nominated-twice/fixtures/gateway.py +284 -284
  85. package/suite/tasks/claim-nominee-uses-slot/fixtures/gateway.py +284 -284
  86. package/suite/tasks/claim-silent-exit/fixtures/gateway.py +235 -235
  87. package/suite/tasks/claim-sse-hang/fixtures/gateway.py +235 -235
  88. package/suite/tasks/claim-unknown-nominee-crash/fixtures/gateway.py +284 -284
  89. package/suite/tasks/rebench-matchms-809-bug/task.json +23 -23
  90. package/suite/tasks/rebench-matchms-809-control/task.json +27 -27
  91. package/suite/tasks/rebench-more-itertools-1028-bug/task.json +23 -23
  92. package/suite/tasks/rebench-more-itertools-1028-control/task.json +27 -27
  93. package/suite/tasks/rebench-narwhals-2864-bug/task.json +23 -23
  94. package/suite/tasks/rebench-narwhals-2864-control/task.json +27 -27
  95. package/suite/tasks/rebench-quantecon-py-769-bug/task.json +23 -23
  96. package/suite/tasks/rebench-quantecon-py-769-control/task.json +27 -27
  97. package/suite/tasks/rebench-scrapy-6867-bug/task.json +23 -23
  98. package/suite/tasks/rebench-scrapy-6867-control/task.json +27 -27
  99. package/ui/bench.css +430 -425
  100. package/ui/bench.js +1757 -1576
  101. package/ui/themes/dark.css +35 -35
  102. package/ui/themes/hardwood.css +52 -52
  103. package/ui/themes/rainforest.css +32 -32
@@ -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
- `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, 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()