@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
package/perf/run.py CHANGED
@@ -1,261 +1,284 @@
1
- """The PERFORMANCE tier: what the serving path does, tracked over time.
2
-
3
- This measures the PATH, not the model. Latency through Open WebUI to whichever
4
- provider is behind it depends on congestion, rate limits, routing and the time
5
- of day — none of which are properties of the model, and all of which decide
6
- whether an offload is worth making right now.
7
-
8
- That makes it the opposite of `suite/` and `floor/`, which are built to hold
9
- still. A single perf number is nearly meaningless. **The history is the
10
- measurement**, which is why every run appends to `perf/history.csv` rather than
11
- overwriting a report: a p50 that doubles over a month is a real finding, and a
12
- p50 that is 40% higher this afternoon is a Tuesday.
13
-
14
- WHAT IS RECORDED, AND WHY EACH
15
- ------------------------------
16
- - **p50 and p95, not the mean.** One 300-second timeout drags a mean into
17
- fiction. p95 is where rate limiting shows up, and rate limiting is the thing
18
- most likely to make a model unusable while looking fine on average.
19
- - **Two prompt sizes.** A one-word prompt measures the FLOOR — connection,
20
- queueing, cold start. A large prompt measures THROUGHPUT. A model can be
21
- fine on one and hopeless on the other, and `nemotron-lightning` (134 s on a
22
- one-word prompt) is why the distinction earns its place.
23
- - **Characters per second, not tokens.** The `llm` CLI does not report token
24
- counts for these endpoints. Chars/s is a proxy, it is affected by how
25
- verbose a model is, and calling it a proxy is more useful than quietly
26
- publishing a number that looks like tokens/s and is not.
27
- - **Outcome counts, split four ways.** answered / refused / error / timeout.
28
- Collapsing these is the mistake this repo was built around: a safety filter,
29
- an overloaded service and a slow model are three different facts.
30
- - **A timestamp on every row**, because the whole point is the trend.
31
-
32
- WHAT THIS CANNOT TELL YOU
33
- -------------------------
34
- Whether a model is any good. That is `suite/`. A model can be the fastest here
35
- and still invent an API that does not exist.
36
- """
37
-
38
- from __future__ import annotations
39
-
40
- import argparse
41
- import csv
42
- import datetime as dt
43
- import os
44
- import pathlib
45
- import statistics
46
- import sys
47
-
48
- HERE = pathlib.Path(__file__).resolve().parent
49
- ROOT = HERE.parent
50
- sys.path.insert(0, str(ROOT / "metrics"))
51
- from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
52
- HISTORY = DATA / "perf" / "history.csv"
53
-
54
- sys.path.insert(0, str(ROOT / "suite"))
55
- from run import (_learn, _registered_names, ad_hoc_models, ask_once, FAILURE_CLASSES,
56
- failure_class, llm_binary, outcome,
57
- registered_models) # noqa: E402
58
-
59
- # A one-word prompt measures overhead; a large one measures throughput. The
60
- # large prompt is generated rather than stored so this file stays readable and
61
- # the size is exact.
62
- TINY = "Reply with exactly one word: ready"
63
- LARGE = (
64
- "Summarise the following configuration in at most three sentences. "
65
- "Do not quote it back.\n\n"
66
- + "\n".join(f"setting_{i} = value_{i} # option {i} of many" for i in range(400))
67
- )
68
-
69
- FIELDS = ["timestamp", "model", "prompt", "n", "p50_s", "p95_s", "min_s",
70
- "max_s", "chars_per_s", "answered", "refused", "error", "timeout",
71
- # WHY it failed, not just that it did. `structural` means the model
72
- # is not there and waiting will not help; `capacity` means it is
73
- # there and busy. The deprecation check reads this to tell a
74
- # retirement apart from a rate limit.
75
- "failure",
76
- # The provider's own words for the worst failure, first 300 chars.
77
- # Without them `glm-5-3` sat as an unexplained `other` for a month
78
- # when the message was a plain "404 page not found".
79
- "detail"]
80
-
81
-
82
- def percentile(values: list[float], pct: float) -> float:
83
- """Nearest-rank percentile.
84
-
85
- `statistics.quantiles` interpolates, which invents a value that was never
86
- measured. With n=5 that is misleading, so this returns a real observation.
87
- """
88
- if not values:
89
- return 0.0
90
- ordered = sorted(values)
91
- k = max(0, min(len(ordered) - 1, int(round(pct * (len(ordered) - 1)))))
92
- return ordered[k]
93
-
94
-
95
- def measure(binary: str, model: str, label: str, prompt: str, n: int,
96
- timeout: int) -> dict:
97
- latencies, rates = [], []
98
- counts = {"answered": 0, "refused": 0, "error": 0, "timeout": 0}
99
- failures: list[str] = []
100
- details: dict[str, str] = {}
101
- for i in range(1, n + 1):
102
- body, seconds = ask_once(binary, model, prompt, timeout)
103
- # A one-word reply is the CORRECT answer to the tiny prompt, so
104
- # only a genuinely empty one counts as a failure here.
105
- kind = (outcome(body, min_length=1) or ("answered", ""))[0]
106
- counts[kind] += 1
107
- # ONLY classify an actual non-answer. `failure_class` uses the
108
- # default minimum reply length, so a correct one-word answer to the
109
- # tiny prompt looked like an empty response and was filed as a
110
- # failure -- on models that answered 3 of 3.
111
- if kind != "answered" and (why := failure_class(body)) is not None:
112
- failures.append(why)
113
- details.setdefault(why, " ".join(body.split())[:300])
114
- _learn(model, body, "perf") # a stated context window, rate limit or wait
115
- # Only a real answer times anything meaningful. A refusal is fast
116
- # because it is short, and averaging that in would flatter the model.
117
- if kind == "answered":
118
- latencies.append(seconds)
119
- # Throughput ONLY from the large prompt. Six characters over ten
120
- # seconds is 0.5 ch/s, which reads as a catastrophically slow
121
- # model and is really just connection overhead with a one-word
122
- # answer attached. A number that invites the wrong reading is
123
- # worse than no number.
124
- if seconds > 0 and label == "large":
125
- rates.append(len(body) / seconds)
126
- print(f" {model} {label} [{i}/{n}] {kind} {seconds:.1f}s", flush=True)
127
- worst = next((c for c in FAILURE_CLASSES if c in failures), "")
128
- return {
129
- "timestamp": dt.datetime.now().isoformat(timespec="seconds"),
130
- "model": model, "prompt": label, "n": n,
131
- "p50_s": round(percentile(latencies, 0.50), 2),
132
- "p95_s": round(percentile(latencies, 0.95), 2),
133
- "min_s": round(min(latencies), 2) if latencies else 0.0,
134
- "max_s": round(max(latencies), 2) if latencies else 0.0,
135
- "chars_per_s": round(statistics.median(rates), 1) if rates else 0.0,
136
- # The worst class seen: structural beats capacity beats timeout, so a
137
- # model that failed structurally even once is not filed as merely busy.
138
- # ORDER COMES FROM `suite/run.py`, not from a copy kept here -- a copy
139
- # is what silently dropped `paywalled` and wrote a blank failure.
140
- "failure": worst,
141
- "detail": details.get(worst, ""),
142
- **counts,
143
- }
144
-
145
-
146
- def append(rows: list[dict]) -> None:
147
- """Append, MIGRATING the file first if its header is out of date.
148
-
149
- A column was added to FIELDS while a run was in flight. The running process
150
- kept the old header, and the next append would have written fourteen values
151
- under thirteen headings — every row silently shifted by one, which is worse
152
- than a crash because the file still parses. History is only worth keeping
153
- if it cannot quietly rot.
154
- """
155
- if HISTORY.exists():
156
- with HISTORY.open(encoding="utf-8", newline="") as fh:
157
- existing = list(csv.DictReader(fh))
158
- header = (csv.reader(HISTORY.open(encoding="utf-8"))
159
- .__next__() if existing else FIELDS)
160
- if header != FIELDS:
161
- with HISTORY.open("w", encoding="utf-8", newline="") as fh:
162
- writer = csv.DictWriter(fh, fieldnames=FIELDS)
163
- writer.writeheader()
164
- writer.writerows({k: r.get(k, "") for k in FIELDS}
165
- for r in existing)
166
-
167
- new = not HISTORY.exists()
168
- with HISTORY.open("a", encoding="utf-8", newline="") as fh:
169
- writer = csv.DictWriter(fh, fieldnames=FIELDS)
170
- if new:
171
- writer.writeheader()
172
- writer.writerows(rows)
173
-
174
-
175
- def trend(model: str, label: str) -> str:
176
- """Compare against this model's previous reading, if there is one."""
177
- if not HISTORY.exists():
178
- return ""
179
- prior = [r for r in csv.DictReader(HISTORY.open(encoding="utf-8"))
180
- if r["model"] == model and r["prompt"] == label]
181
- if len(prior) < 2:
182
- return ""
183
- now, before = float(prior[-1]["p50_s"]), float(prior[-2]["p50_s"])
184
- if before == 0:
185
- return ""
186
- change = (now - before) / before * 100
187
- if abs(change) < 15:
188
- return " (steady)"
189
- return f" ({change:+.0f}% vs {prior[-2]['timestamp'][:10]})"
190
-
191
-
192
- def main() -> None:
193
- ap = argparse.ArgumentParser(description=__doc__)
194
- ap.add_argument("--model", action="append", dest="models")
195
- ap.add_argument("-n", type=int, default=5,
196
- help="samples per prompt size; more is better and slower")
197
- ap.add_argument("--timeout", type=int, default=180)
198
- ap.add_argument("--tiny-only", action="store_true",
199
- help="skip the large prompt (a quick availability check)")
200
- ap.add_argument("--client", choices=("llm", "builtin"),
201
- help="which model client makes the calls (metrics/client.py);"
202
- " default: BENCH_CLIENT, else llm")
203
- args = ap.parse_args()
204
- if args.client:
205
- # ask_once reads it per call; set here because a scheduled task's
206
- # action can pass arguments but not environment variables.
207
- os.environ["BENCH_CLIENT"] = args.client
208
-
209
- # Every registered model, derived from the yaml — a perf run that
210
- # skipped a model would hide exactly the outage it exists to find.
211
- # Except the AD HOC ones: they are capped or expensive, and a daily
212
- # probe would spend the cap on a latency number. Measured only when
213
- # named with --model.
214
- held = ad_hoc_models()
215
- models = args.models or [m for m in registered_models() if m not in held]
216
- if not args.models and held:
217
- print(f"held back (ad hoc — name with --model to measure): "
218
- f"{', '.join(sorted(held))}")
219
- prompts = [("tiny", TINY)] if args.tiny_only else [("tiny", TINY),
220
- ("large", LARGE)]
221
- binary = llm_binary()
222
- print(f"{len(models)} models x {len(prompts)} prompts x {args.n} samples")
223
- print(f"large prompt is {len(LARGE):,} chars\n")
224
-
225
- # OpenRouter's `:free` models share ONE daily budget — 50 requests without
226
- # credits — so a full weekly sweep at n=3 spends 54 on them alone and the
227
- # last few get throttled. That is recorded honestly as `capacity` rather
228
- # than as a fault, but it still means their numbers are measuring the
229
- # quota. One sample each keeps the whole free tier inside the budget with
230
- # room to spare, at the cost of no percentile spread for those models.
231
- served = _registered_names()
232
- rows = []
233
- for model in models:
234
- n = 1 if ":free" in served.get(model, "") else args.n
235
- for label, prompt in prompts:
236
- rows.append(measure(binary, model, label, prompt, n,
237
- args.timeout))
238
- try:
239
- sys.path.insert(0, str(ROOT / "metrics"))
240
- from cost import note_benchmark_calls
241
- for row in rows:
242
- note_benchmark_calls(row["model"], row["n"])
243
- except Exception: # noqa: BLE001
244
- pass
245
-
246
- append(rows)
247
-
248
- print(f"\n{'model':<24} {'prompt':<7} {'p50':>7} {'p95':>7} {'ch/s':>8}"
249
- f" outcomes")
250
- for r in rows:
251
- oc = (f"{r['answered']}a/{r['refused']}r/{r['error']}e/{r['timeout']}t")
252
- print(f"{r['model']:<24} {r['prompt']:<7} {r['p50_s']:>7.1f} "
253
- f"{r['p95_s']:>7.1f} {r['chars_per_s']:>8.1f} {oc}"
254
- + trend(r["model"], r["prompt"]))
255
- print(f"\nappended {len(rows)} rows to {HISTORY}")
256
- print("These numbers measure the SERVING PATH, not the models. "
257
- "One reading is weather; the history is climate.")
258
-
259
-
260
- if __name__ == "__main__":
261
- main()
1
+ """The PERFORMANCE tier: what the serving path does, tracked over time.
2
+
3
+ This measures the PATH, not the model. Latency through Open WebUI to whichever
4
+ provider is behind it depends on congestion, rate limits, routing and the time
5
+ of day — none of which are properties of the model, and all of which decide
6
+ whether an offload is worth making right now.
7
+
8
+ That makes it the opposite of `suite/` and `floor/`, which are built to hold
9
+ still. A single perf number is nearly meaningless. **The history is the
10
+ measurement**, which is why every run appends to `perf/history.csv` rather than
11
+ overwriting a report: a p50 that doubles over a month is a real finding, and a
12
+ p50 that is 40% higher this afternoon is a Tuesday.
13
+
14
+ WHAT IS RECORDED, AND WHY EACH
15
+ ------------------------------
16
+ - **p50 and p95, not the mean.** One 300-second timeout drags a mean into
17
+ fiction. p95 is where rate limiting shows up, and rate limiting is the thing
18
+ most likely to make a model unusable while looking fine on average.
19
+ - **Two prompt sizes.** A one-word prompt measures the FLOOR — connection,
20
+ queueing, cold start. A large prompt measures THROUGHPUT. A model can be
21
+ fine on one and hopeless on the other, and `nemotron-lightning` (134 s on a
22
+ one-word prompt) is why the distinction earns its place.
23
+ - **Characters per second, and tokens per second where they are real.** The
24
+ `llm` CLI does not report token counts for these endpoints, so chars/s is
25
+ kept as the proxy every row has; it is affected by how verbose a model is.
26
+ The built-in client reads the provider's own usage from the stream (Phase
27
+ 2b), and with it `tokens_per_s` is the real thing: output tokens (reasoning
28
+ included, which is generated like any other) over the time from the first
29
+ token to the last, so queueing and connection time are not in it. Blank
30
+ where the provider gave no usage, never estimated.
31
+ - **Outcome counts, split four ways.** answered / refused / error / timeout.
32
+ Collapsing these is the mistake this repo was built around: a safety filter,
33
+ an overloaded service and a slow model are three different facts.
34
+ - **A timestamp on every row**, because the whole point is the trend.
35
+
36
+ WHAT THIS CANNOT TELL YOU
37
+ -------------------------
38
+ Whether a model is any good. That is `suite/`. A model can be the fastest here
39
+ and still invent an API that does not exist.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import argparse
45
+ import csv
46
+ import datetime as dt
47
+ import os
48
+ import pathlib
49
+ import statistics
50
+ import sys
51
+
52
+ HERE = pathlib.Path(__file__).resolve().parent
53
+ ROOT = HERE.parent
54
+ sys.path.insert(0, str(ROOT / "metrics"))
55
+ from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
56
+ HISTORY = DATA / "perf" / "history.csv"
57
+
58
+ sys.path.insert(0, str(ROOT / "suite"))
59
+ from run import (_learn, _registered_names, ad_hoc_models, ask_once, FAILURE_CLASSES,
60
+ failure_class, llm_binary, outcome,
61
+ registered_models) # noqa: E402
62
+
63
+ # A one-word prompt measures overhead; a large one measures throughput. The
64
+ # large prompt is generated rather than stored so this file stays readable and
65
+ # the size is exact.
66
+ TINY = "Reply with exactly one word: ready"
67
+ LARGE = (
68
+ "Summarise the following configuration in at most three sentences. "
69
+ "Do not quote it back.\n\n"
70
+ + "\n".join(f"setting_{i} = value_{i} # option {i} of many" for i in range(400))
71
+ )
72
+
73
+ FIELDS = ["timestamp", "model", "prompt", "n", "p50_s", "p95_s", "min_s",
74
+ "max_s", "chars_per_s", "answered", "refused", "error", "timeout",
75
+ # WHY it failed, not just that it did. `structural` means the model
76
+ # is not there and waiting will not help; `capacity` means it is
77
+ # there and busy. The deprecation check reads this to tell a
78
+ # retirement apart from a rate limit.
79
+ "failure",
80
+ # The provider's own words for the worst failure, first 300 chars.
81
+ # Without them `glm-5-3` sat as an unexplained `other` for a month
82
+ # when the message was a plain "404 page not found".
83
+ "detail",
84
+ # Real output tokens/s from the provider's usage (built-in client only).
85
+ "tokens_per_s"]
86
+
87
+
88
+ def percentile(values: list[float], pct: float) -> float:
89
+ """Nearest-rank percentile.
90
+
91
+ `statistics.quantiles` interpolates, which invents a value that was never
92
+ measured. With n=5 that is misleading, so this returns a real observation.
93
+ """
94
+ if not values:
95
+ return 0.0
96
+ ordered = sorted(values)
97
+ k = max(0, min(len(ordered) - 1, int(round(pct * (len(ordered) - 1)))))
98
+ return ordered[k]
99
+
100
+
101
+ def _ask(binary: str, model: str, prompt: str, timeout: int) -> tuple[str, float, float | None]:
102
+ """ask_once's (text, seconds), plus output tokens/s when the built-in
103
+ client made the call and the provider reported usage."""
104
+ import client
105
+ if client.backend(model) != "builtin":
106
+ return (*ask_once(binary, model, prompt, timeout), None)
107
+ res = client.chat(model, prompt, timeout=timeout, purpose="perf")
108
+ out = (res["usage"] or {}).get("completion_tokens") or (res["usage"] or {}).get("output_tokens")
109
+ # From the first token on, so connection and queueing time are left out.
110
+ span = res["seconds"] - (res.get("first_token_s") or 0)
111
+ rate = out / span if isinstance(out, int) and out > 0 and span > 0 and not res["error"] else None
112
+ return client.output(res, timeout), res["seconds"], rate
113
+
114
+
115
+ def measure(binary: str, model: str, label: str, prompt: str, n: int,
116
+ timeout: int) -> dict:
117
+ latencies, rates, token_rates = [], [], []
118
+ counts = {"answered": 0, "refused": 0, "error": 0, "timeout": 0}
119
+ failures: list[str] = []
120
+ details: dict[str, str] = {}
121
+ for i in range(1, n + 1):
122
+ body, seconds, tokens_per_s = _ask(binary, model, prompt, timeout)
123
+ # A one-word reply is the CORRECT answer to the tiny prompt, so
124
+ # only a genuinely empty one counts as a failure here.
125
+ kind = (outcome(body, min_length=1) or ("answered", ""))[0]
126
+ counts[kind] += 1
127
+ # ONLY classify an actual non-answer. `failure_class` uses the
128
+ # default minimum reply length, so a correct one-word answer to the
129
+ # tiny prompt looked like an empty response and was filed as a
130
+ # failure -- on models that answered 3 of 3.
131
+ if kind != "answered" and (why := failure_class(body)) is not None:
132
+ failures.append(why)
133
+ details.setdefault(why, " ".join(body.split())[:300])
134
+ _learn(model, body, "perf") # a stated context window, rate limit or wait
135
+ # Only a real answer times anything meaningful. A refusal is fast
136
+ # because it is short, and averaging that in would flatter the model.
137
+ if kind == "answered":
138
+ latencies.append(seconds)
139
+ # Throughput ONLY from the large prompt. Six characters over ten
140
+ # seconds is 0.5 ch/s, which reads as a catastrophically slow
141
+ # model and is really just connection overhead with a one-word
142
+ # answer attached. A number that invites the wrong reading is
143
+ # worse than no number.
144
+ if seconds > 0 and label == "large":
145
+ rates.append(len(body) / seconds)
146
+ if tokens_per_s:
147
+ token_rates.append(tokens_per_s)
148
+ print(f" {model} {label} [{i}/{n}] {kind} {seconds:.1f}s", flush=True)
149
+ worst = next((c for c in FAILURE_CLASSES if c in failures), "")
150
+ return {
151
+ "timestamp": dt.datetime.now().isoformat(timespec="seconds"),
152
+ "model": model, "prompt": label, "n": n,
153
+ "p50_s": round(percentile(latencies, 0.50), 2),
154
+ "p95_s": round(percentile(latencies, 0.95), 2),
155
+ "min_s": round(min(latencies), 2) if latencies else 0.0,
156
+ "max_s": round(max(latencies), 2) if latencies else 0.0,
157
+ "chars_per_s": round(statistics.median(rates), 1) if rates else 0.0,
158
+ # The worst class seen: structural beats capacity beats timeout, so a
159
+ # model that failed structurally even once is not filed as merely busy.
160
+ # ORDER COMES FROM `suite/run.py`, not from a copy kept here -- a copy
161
+ # is what silently dropped `paywalled` and wrote a blank failure.
162
+ "failure": worst,
163
+ "detail": details.get(worst, ""),
164
+ "tokens_per_s": round(statistics.median(token_rates), 1) if token_rates else "",
165
+ **counts,
166
+ }
167
+
168
+
169
+ def append(rows: list[dict]) -> None:
170
+ """Append, MIGRATING the file first if its header is out of date.
171
+
172
+ A column was added to FIELDS while a run was in flight. The running process
173
+ kept the old header, and the next append would have written fourteen values
174
+ under thirteen headings — every row silently shifted by one, which is worse
175
+ than a crash because the file still parses. History is only worth keeping
176
+ if it cannot quietly rot.
177
+ """
178
+ if HISTORY.exists():
179
+ with HISTORY.open(encoding="utf-8", newline="") as fh:
180
+ existing = list(csv.DictReader(fh))
181
+ header = (csv.reader(HISTORY.open(encoding="utf-8"))
182
+ .__next__() if existing else FIELDS)
183
+ if header != FIELDS:
184
+ with HISTORY.open("w", encoding="utf-8", newline="") as fh:
185
+ writer = csv.DictWriter(fh, fieldnames=FIELDS)
186
+ writer.writeheader()
187
+ writer.writerows({k: r.get(k, "") for k in FIELDS}
188
+ for r in existing)
189
+
190
+ new = not HISTORY.exists()
191
+ with HISTORY.open("a", encoding="utf-8", newline="") as fh:
192
+ writer = csv.DictWriter(fh, fieldnames=FIELDS)
193
+ if new:
194
+ writer.writeheader()
195
+ writer.writerows(rows)
196
+
197
+
198
+ def trend(model: str, label: str) -> str:
199
+ """Compare against this model's previous reading, if there is one."""
200
+ if not HISTORY.exists():
201
+ return ""
202
+ prior = [r for r in csv.DictReader(HISTORY.open(encoding="utf-8"))
203
+ if r["model"] == model and r["prompt"] == label]
204
+ if len(prior) < 2:
205
+ return ""
206
+ now, before = float(prior[-1]["p50_s"]), float(prior[-2]["p50_s"])
207
+ if before == 0:
208
+ return ""
209
+ change = (now - before) / before * 100
210
+ if abs(change) < 15:
211
+ return " (steady)"
212
+ return f" ({change:+.0f}% vs {prior[-2]['timestamp'][:10]})"
213
+
214
+
215
+ def main() -> None:
216
+ ap = argparse.ArgumentParser(description=__doc__)
217
+ ap.add_argument("--model", action="append", dest="models")
218
+ ap.add_argument("-n", type=int, default=5,
219
+ help="samples per prompt size; more is better and slower")
220
+ ap.add_argument("--timeout", type=int, default=180)
221
+ ap.add_argument("--tiny-only", action="store_true",
222
+ help="skip the large prompt (a quick availability check)")
223
+ ap.add_argument("--client", choices=("llm", "builtin"),
224
+ help="which model client makes the calls (metrics/client.py);"
225
+ " default: BENCH_CLIENT, else llm")
226
+ args = ap.parse_args()
227
+ if args.client:
228
+ # ask_once reads it per call; set here because a scheduled task's
229
+ # action can pass arguments but not environment variables.
230
+ os.environ["BENCH_CLIENT"] = args.client
231
+
232
+ # Every registered model, derived from the yaml — a perf run that
233
+ # skipped a model would hide exactly the outage it exists to find.
234
+ # Except the AD HOC ones: they are capped or expensive, and a daily
235
+ # probe would spend the cap on a latency number. Measured only when
236
+ # named with --model.
237
+ held = ad_hoc_models()
238
+ models = args.models or [m for m in registered_models() if m not in held]
239
+ if not args.models and held:
240
+ print(f"held back (ad hoc — name with --model to measure): "
241
+ f"{', '.join(sorted(held))}")
242
+ prompts = [("tiny", TINY)] if args.tiny_only else [("tiny", TINY),
243
+ ("large", LARGE)]
244
+ binary = llm_binary()
245
+ print(f"{len(models)} models x {len(prompts)} prompts x {args.n} samples")
246
+ print(f"large prompt is {len(LARGE):,} chars\n")
247
+
248
+ # OpenRouter's `:free` models share ONE daily budget — 50 requests without
249
+ # credits — so a full weekly sweep at n=3 spends 54 on them alone and the
250
+ # last few get throttled. That is recorded honestly as `capacity` rather
251
+ # than as a fault, but it still means their numbers are measuring the
252
+ # quota. One sample each keeps the whole free tier inside the budget with
253
+ # room to spare, at the cost of no percentile spread for those models.
254
+ served = _registered_names()
255
+ rows = []
256
+ for model in models:
257
+ n = 1 if ":free" in served.get(model, "") else args.n
258
+ for label, prompt in prompts:
259
+ rows.append(measure(binary, model, label, prompt, n,
260
+ args.timeout))
261
+ try:
262
+ sys.path.insert(0, str(ROOT / "metrics"))
263
+ from cost import note_benchmark_calls
264
+ for row in rows:
265
+ note_benchmark_calls(row["model"], row["n"])
266
+ except Exception: # noqa: BLE001
267
+ pass
268
+
269
+ append(rows)
270
+
271
+ print(f"\n{'model':<24} {'prompt':<7} {'p50':>7} {'p95':>7} {'ch/s':>8}"
272
+ f" outcomes")
273
+ for r in rows:
274
+ oc = (f"{r['answered']}a/{r['refused']}r/{r['error']}e/{r['timeout']}t")
275
+ print(f"{r['model']:<24} {r['prompt']:<7} {r['p50_s']:>7.1f} "
276
+ f"{r['p95_s']:>7.1f} {r['chars_per_s']:>8.1f} {oc}"
277
+ + trend(r["model"], r["prompt"]))
278
+ print(f"\nappended {len(rows)} rows to {HISTORY}")
279
+ print("These numbers measure the SERVING PATH, not the models. "
280
+ "One reading is weather; the history is climate.")
281
+
282
+
283
+ if __name__ == "__main__":
284
+ main()