@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
package/perf/run.py CHANGED
@@ -1,261 +1,261 @@
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, 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()