@wendellresearch-ai/benchwarmers 0.2.0 → 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 -272
  4. package/NOTICE +28 -28
  5. package/PRIVACY.md +46 -46
  6. package/README.md +368 -314
  7. package/SECURITY.md +83 -83
  8. package/TERMS.md +22 -22
  9. package/USAGE.md +170 -163
  10. package/VERSION +1 -1
  11. package/bin/benchwarmers.js +16 -74
  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 -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 +295 -295
  22. package/metrics/client.py +394 -394
  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 -579
  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 -65
  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 -106
  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 -81
  62. package/scripts/test_mcp.py +178 -178
  63. package/scripts/test_merged.py +214 -214
  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 -438
  67. package/scripts/test_verdict.py +98 -98
  68. package/scripts/test_vision.py +228 -228
  69. package/serve.py +3904 -3764
  70. package/suite/mine.py +154 -154
  71. package/suite/pace.py +145 -145
  72. package/suite/run.py +1046 -1042
  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 -425
  93. package/ui/bench.js +1756 -1576
  94. package/ui/themes/dark.css +35 -35
  95. package/ui/themes/hardwood.css +52 -52
  96. package/ui/themes/rainforest.css +32 -32
@@ -1,187 +1,187 @@
1
- """Ask a decision model a typed question: choose, score, or yes/no.
2
-
3
- python metrics/decide.py mercury-decide-free "List every place serve.py writes a file." \\
4
- --choice review,design,enumeration,code
5
- python metrics/decide.py span-01-lite-free "Please refund the duplicate charge." \\
6
- --noul "Is this about billing?"
7
-
8
- DECISION MODELS ARE NOT CHAT MODELS
9
- -----------------------------------
10
- Mercury Decide and Respan take a `state` and a map of typed `questions` and
11
- return each answer with a probability. They refuse /chat/completions outright
12
- ("is a decisions model and cannot be used with the chat/completions endpoint"),
13
- so `llm -m` cannot reach them, the availability probe and the suite cannot
14
- test them, and they never go in the llm yaml. They live in
15
- `config/decision-models.json` instead, written by `scripts/onboard.py` when a
16
- model answers a typed question.
17
-
18
- WHERE THE CALL GOES
19
- -------------------
20
- Each endpoint in `config/endpoints.json` may carry a `decisions` URL. Open
21
- WebUI has one only through its `/openai/{path}` passthrough, which forwards to
22
- `{connection base}/decisions` and answers only when the server runs with
23
- ENABLE_OPENAI_API_PASSTHROUGH=True. That reaches Inception
24
- (`api.inceptionlabs.ai/v1/decisions`) but never OpenRouter, whose Decisions API
25
- is `/api/alpha/decisions`, outside its `/api/v1` base.
26
-
27
- A model is called on the endpoint it was found on, recorded as `via`. Traffic
28
- goes through Open WebUI by design; the direct endpoints are for named special
29
- cases, so a different `via` happens only when asked for (`onboard.py --via`).
30
-
31
- WHAT EACH MODEL CAN ANSWER
32
- --------------------------
33
- `question_types` lists the types a model accepted when onboarding probed it
34
- with one question of each: Mercury Decide takes noul, choice and score;
35
- Respan takes noul only, with plain-string instructions and criteria. `ask`
36
- refuses a type the entry does not list, so a caller learns it from the bench
37
- rather than from a 400. Both providers take the same body (`model`, `state`,
38
- `questions`) and return the same `answers`, so one client covers them.
39
- """
40
-
41
- from __future__ import annotations
42
-
43
- import argparse
44
- import json
45
- import pathlib
46
- import sys
47
- import urllib.error
48
- import urllib.request
49
-
50
- ROOT = pathlib.Path(__file__).resolve().parent.parent
51
- FILE = ROOT / "config" / "decision-models.json"
52
- sys.path.insert(0, str(ROOT / "metrics"))
53
- import endpoints # noqa: E402
54
-
55
- # One question of each type, plain strings throughout so Respan's rule holds.
56
- PROBES = {
57
- "noul": {"type": "noul", "instructions": "Is this message about billing?",
58
- "criteria": {"true": "It is about charges, payments or refunds.",
59
- "false": "It is about something else."}},
60
- "choice": {"type": "choice", "instructions": "Which team should handle this message?",
61
- "criteria": {"billing": "Charges, payments, refunds.",
62
- "support": "Product problems.", "sales": "Buying new things."}},
63
- "score": {"type": "score", "instructions": "How urgent is this message?",
64
- "criteria": ["Can wait", "Needs attention soon", "Needs immediate attention"]},
65
- }
66
- PROBE_STATE = "Please refund the duplicate charge on my account today."
67
-
68
-
69
- def load() -> dict[str, dict]:
70
- try:
71
- return json.loads(FILE.read_text(encoding="utf-8"))
72
- except (OSError, ValueError):
73
- return {}
74
-
75
-
76
- def call(endpoint: str, served: str, state, questions: dict, timeout: int = 60) -> dict:
77
- """POST to an endpoint's decisions URL. Returns the parsed body; raises on HTTP errors."""
78
- entry = endpoints.load()[endpoint]
79
- url = entry.get("decisions")
80
- if not url:
81
- raise ValueError(f"{endpoint} has no decisions URL in config/endpoints.json")
82
- headers = {"Content-Type": "application/json", **(entry.get("headers") or {})}
83
- if (secret := endpoints.key(endpoint)):
84
- headers["Authorization"] = f"Bearer {secret}"
85
- body = json.dumps({"model": served, "state": state, "questions": questions}).encode()
86
- request = urllib.request.Request(url, data=body, headers=headers, method="POST")
87
- with urllib.request.urlopen(request, timeout=timeout) as response:
88
- return json.load(response)
89
-
90
-
91
- def error_text(exc: Exception) -> str:
92
- if isinstance(exc, urllib.error.HTTPError):
93
- try:
94
- detail = json.loads(exc.read() or b"{}")
95
- detail = detail.get("error", detail).get("message", detail) if isinstance(detail, dict) else detail
96
- except ValueError:
97
- detail = ""
98
- return f"{exc.code} {str(detail)[:160]}".strip()
99
- return f"{type(exc).__name__}: {exc}"
100
-
101
-
102
- def probe(endpoint: str, served: str) -> str | None:
103
- """None if `endpoint`'s decisions URL answers a yes/no question for
104
- `served`; otherwise why not."""
105
- if not endpoints.load()[endpoint].get("decisions"):
106
- return f"{endpoint} has no decisions URL in config/endpoints.json"
107
- try:
108
- answer = call(endpoint, served, PROBE_STATE, {"probe": PROBES["noul"]})
109
- except Exception as exc: # noqa: BLE001
110
- return f"{endpoint}: {error_text(exc)}"
111
- return None if "probe" in answer.get("answers", {}) else f"{endpoint}: no answer in {str(answer)[:120]}"
112
-
113
-
114
- def refuses_chat(endpoint: str, served: str) -> bool:
115
- """True if a one-token chat call is refused because the model is a decision
116
- model ("is a decisions model and cannot be used with the chat/completions
117
- endpoint", as OpenRouter words it, relayed by Open WebUI)."""
118
- entry = endpoints.load()[endpoint]
119
- headers = {"Content-Type": "application/json", **(entry.get("headers") or {})}
120
- if (secret := endpoints.key(endpoint)):
121
- headers["Authorization"] = f"Bearer {secret}"
122
- body = json.dumps({"model": served, "max_tokens": 1,
123
- "messages": [{"role": "user", "content": "ready?"}]}).encode()
124
- request = urllib.request.Request(endpoints.base(endpoint) + "/chat/completions",
125
- data=body, headers=headers, method="POST")
126
- try:
127
- with urllib.request.urlopen(request, timeout=60):
128
- return False
129
- except urllib.error.HTTPError as exc:
130
- return "decisions model" in error_text(exc).lower()
131
- except Exception: # noqa: BLE001
132
- return False
133
-
134
-
135
- def question_types(endpoint: str, served: str) -> list[str]:
136
- """Which question types the model accepts, one probe call per type."""
137
- ok = []
138
- for kind, question in PROBES.items():
139
- try:
140
- if kind in call(endpoint, served, PROBE_STATE, {kind: question}).get("answers", {}):
141
- ok.append(kind)
142
- except Exception: # noqa: BLE001
143
- pass
144
- return ok
145
-
146
-
147
- def ask(alias: str, state, questions: dict) -> dict:
148
- entry = load()[alias]
149
- wrong = sorted({q["type"] for q in questions.values()} - set(entry["question_types"]))
150
- if wrong:
151
- raise ValueError(f"{alias} does not take {', '.join(wrong)} questions;"
152
- f" it takes {', '.join(entry['question_types'])}")
153
- return call(entry["via"], entry["served"], state, questions)
154
-
155
-
156
- def main() -> None:
157
- sys.stdout.reconfigure(encoding="utf-8", errors="replace")
158
- ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
159
- ap.add_argument("alias", help="a key of config/decision-models.json")
160
- ap.add_argument("state", help="the text being judged")
161
- group = ap.add_mutually_exclusive_group(required=True)
162
- group.add_argument("--noul", metavar="QUESTION", help="a yes/no question")
163
- group.add_argument("--choice", metavar="A,B,C", help="comma-separated options")
164
- group.add_argument("--score", metavar="L0,L1,L2", help="comma-separated levels, lowest first")
165
- ap.add_argument("--instructions", default="Answer the question about this text.")
166
- args = ap.parse_args()
167
- if args.alias not in load():
168
- sys.exit(f"{args.alias} is not in {FILE.name}; onboard it first: scripts/onboard.py <served id>")
169
- if args.noul:
170
- q = {"type": "noul", "instructions": args.noul,
171
- "criteria": {"true": "Yes.", "false": "No."}}
172
- elif args.choice:
173
- q = {"type": "choice", "instructions": args.instructions,
174
- "criteria": {o.strip(): None for o in args.choice.split(",")}}
175
- else:
176
- q = {"type": "score", "instructions": args.instructions,
177
- "criteria": [o.strip() for o in args.score.split(",")]}
178
- try:
179
- print(json.dumps(ask(args.alias, args.state, {"q": q})["answers"]["q"], indent=1))
180
- except ValueError as exc:
181
- sys.exit(str(exc))
182
- except Exception as exc: # noqa: BLE001
183
- sys.exit(error_text(exc))
184
-
185
-
186
- if __name__ == "__main__":
187
- main()
1
+ """Ask a decision model a typed question: choose, score, or yes/no.
2
+
3
+ python metrics/decide.py mercury-decide-free "List every place serve.py writes a file." \\
4
+ --choice review,design,enumeration,code
5
+ python metrics/decide.py span-01-lite-free "Please refund the duplicate charge." \\
6
+ --noul "Is this about billing?"
7
+
8
+ DECISION MODELS ARE NOT CHAT MODELS
9
+ -----------------------------------
10
+ Mercury Decide and Respan take a `state` and a map of typed `questions` and
11
+ return each answer with a probability. They refuse /chat/completions outright
12
+ ("is a decisions model and cannot be used with the chat/completions endpoint"),
13
+ so `llm -m` cannot reach them, the availability probe and the suite cannot
14
+ test them, and they never go in the llm yaml. They live in
15
+ `config/decision-models.json` instead, written by `scripts/onboard.py` when a
16
+ model answers a typed question.
17
+
18
+ WHERE THE CALL GOES
19
+ -------------------
20
+ Each endpoint in `config/endpoints.json` may carry a `decisions` URL. Open
21
+ WebUI has one only through its `/openai/{path}` passthrough, which forwards to
22
+ `{connection base}/decisions` and answers only when the server runs with
23
+ ENABLE_OPENAI_API_PASSTHROUGH=True. That reaches Inception
24
+ (`api.inceptionlabs.ai/v1/decisions`) but never OpenRouter, whose Decisions API
25
+ is `/api/alpha/decisions`, outside its `/api/v1` base.
26
+
27
+ A model is called on the endpoint it was found on, recorded as `via`. Traffic
28
+ goes through Open WebUI by design; the direct endpoints are for named special
29
+ cases, so a different `via` happens only when asked for (`onboard.py --via`).
30
+
31
+ WHAT EACH MODEL CAN ANSWER
32
+ --------------------------
33
+ `question_types` lists the types a model accepted when onboarding probed it
34
+ with one question of each: Mercury Decide takes noul, choice and score;
35
+ Respan takes noul only, with plain-string instructions and criteria. `ask`
36
+ refuses a type the entry does not list, so a caller learns it from the bench
37
+ rather than from a 400. Both providers take the same body (`model`, `state`,
38
+ `questions`) and return the same `answers`, so one client covers them.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import argparse
44
+ import json
45
+ import pathlib
46
+ import sys
47
+ import urllib.error
48
+ import urllib.request
49
+
50
+ ROOT = pathlib.Path(__file__).resolve().parent.parent
51
+ FILE = ROOT / "config" / "decision-models.json"
52
+ sys.path.insert(0, str(ROOT / "metrics"))
53
+ import endpoints # noqa: E402
54
+
55
+ # One question of each type, plain strings throughout so Respan's rule holds.
56
+ PROBES = {
57
+ "noul": {"type": "noul", "instructions": "Is this message about billing?",
58
+ "criteria": {"true": "It is about charges, payments or refunds.",
59
+ "false": "It is about something else."}},
60
+ "choice": {"type": "choice", "instructions": "Which team should handle this message?",
61
+ "criteria": {"billing": "Charges, payments, refunds.",
62
+ "support": "Product problems.", "sales": "Buying new things."}},
63
+ "score": {"type": "score", "instructions": "How urgent is this message?",
64
+ "criteria": ["Can wait", "Needs attention soon", "Needs immediate attention"]},
65
+ }
66
+ PROBE_STATE = "Please refund the duplicate charge on my account today."
67
+
68
+
69
+ def load() -> dict[str, dict]:
70
+ try:
71
+ return json.loads(FILE.read_text(encoding="utf-8"))
72
+ except (OSError, ValueError):
73
+ return {}
74
+
75
+
76
+ def call(endpoint: str, served: str, state, questions: dict, timeout: int = 60) -> dict:
77
+ """POST to an endpoint's decisions URL. Returns the parsed body; raises on HTTP errors."""
78
+ entry = endpoints.load()[endpoint]
79
+ url = entry.get("decisions")
80
+ if not url:
81
+ raise ValueError(f"{endpoint} has no decisions URL in config/endpoints.json")
82
+ headers = {"Content-Type": "application/json", **(entry.get("headers") or {})}
83
+ if (secret := endpoints.key(endpoint)):
84
+ headers["Authorization"] = f"Bearer {secret}"
85
+ body = json.dumps({"model": served, "state": state, "questions": questions}).encode()
86
+ request = urllib.request.Request(url, data=body, headers=headers, method="POST")
87
+ with urllib.request.urlopen(request, timeout=timeout) as response:
88
+ return json.load(response)
89
+
90
+
91
+ def error_text(exc: Exception) -> str:
92
+ if isinstance(exc, urllib.error.HTTPError):
93
+ try:
94
+ detail = json.loads(exc.read() or b"{}")
95
+ detail = detail.get("error", detail).get("message", detail) if isinstance(detail, dict) else detail
96
+ except ValueError:
97
+ detail = ""
98
+ return f"{exc.code} {str(detail)[:160]}".strip()
99
+ return f"{type(exc).__name__}: {exc}"
100
+
101
+
102
+ def probe(endpoint: str, served: str) -> str | None:
103
+ """None if `endpoint`'s decisions URL answers a yes/no question for
104
+ `served`; otherwise why not."""
105
+ if not endpoints.load()[endpoint].get("decisions"):
106
+ return f"{endpoint} has no decisions URL in config/endpoints.json"
107
+ try:
108
+ answer = call(endpoint, served, PROBE_STATE, {"probe": PROBES["noul"]})
109
+ except Exception as exc: # noqa: BLE001
110
+ return f"{endpoint}: {error_text(exc)}"
111
+ return None if "probe" in answer.get("answers", {}) else f"{endpoint}: no answer in {str(answer)[:120]}"
112
+
113
+
114
+ def refuses_chat(endpoint: str, served: str) -> bool:
115
+ """True if a one-token chat call is refused because the model is a decision
116
+ model ("is a decisions model and cannot be used with the chat/completions
117
+ endpoint", as OpenRouter words it, relayed by Open WebUI)."""
118
+ entry = endpoints.load()[endpoint]
119
+ headers = {"Content-Type": "application/json", **(entry.get("headers") or {})}
120
+ if (secret := endpoints.key(endpoint)):
121
+ headers["Authorization"] = f"Bearer {secret}"
122
+ body = json.dumps({"model": served, "max_tokens": 1,
123
+ "messages": [{"role": "user", "content": "ready?"}]}).encode()
124
+ request = urllib.request.Request(endpoints.base(endpoint) + "/chat/completions",
125
+ data=body, headers=headers, method="POST")
126
+ try:
127
+ with urllib.request.urlopen(request, timeout=60):
128
+ return False
129
+ except urllib.error.HTTPError as exc:
130
+ return "decisions model" in error_text(exc).lower()
131
+ except Exception: # noqa: BLE001
132
+ return False
133
+
134
+
135
+ def question_types(endpoint: str, served: str) -> list[str]:
136
+ """Which question types the model accepts, one probe call per type."""
137
+ ok = []
138
+ for kind, question in PROBES.items():
139
+ try:
140
+ if kind in call(endpoint, served, PROBE_STATE, {kind: question}).get("answers", {}):
141
+ ok.append(kind)
142
+ except Exception: # noqa: BLE001
143
+ pass
144
+ return ok
145
+
146
+
147
+ def ask(alias: str, state, questions: dict) -> dict:
148
+ entry = load()[alias]
149
+ wrong = sorted({q["type"] for q in questions.values()} - set(entry["question_types"]))
150
+ if wrong:
151
+ raise ValueError(f"{alias} does not take {', '.join(wrong)} questions;"
152
+ f" it takes {', '.join(entry['question_types'])}")
153
+ return call(entry["via"], entry["served"], state, questions)
154
+
155
+
156
+ def main() -> None:
157
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
158
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
159
+ ap.add_argument("alias", help="a key of config/decision-models.json")
160
+ ap.add_argument("state", help="the text being judged")
161
+ group = ap.add_mutually_exclusive_group(required=True)
162
+ group.add_argument("--noul", metavar="QUESTION", help="a yes/no question")
163
+ group.add_argument("--choice", metavar="A,B,C", help="comma-separated options")
164
+ group.add_argument("--score", metavar="L0,L1,L2", help="comma-separated levels, lowest first")
165
+ ap.add_argument("--instructions", default="Answer the question about this text.")
166
+ args = ap.parse_args()
167
+ if args.alias not in load():
168
+ sys.exit(f"{args.alias} is not in {FILE.name}; onboard it first: scripts/onboard.py <served id>")
169
+ if args.noul:
170
+ q = {"type": "noul", "instructions": args.noul,
171
+ "criteria": {"true": "Yes.", "false": "No."}}
172
+ elif args.choice:
173
+ q = {"type": "choice", "instructions": args.instructions,
174
+ "criteria": {o.strip(): None for o in args.choice.split(",")}}
175
+ else:
176
+ q = {"type": "score", "instructions": args.instructions,
177
+ "criteria": [o.strip() for o in args.score.split(",")]}
178
+ try:
179
+ print(json.dumps(ask(args.alias, args.state, {"q": q})["answers"]["q"], indent=1))
180
+ except ValueError as exc:
181
+ sys.exit(str(exc))
182
+ except Exception as exc: # noqa: BLE001
183
+ sys.exit(error_text(exc))
184
+
185
+
186
+ if __name__ == "__main__":
187
+ main()