@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/metrics/decide.py CHANGED
@@ -1,227 +1,227 @@
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 picked from when onboarding, recorded
28
- as `via`: picked from Open WebUI, it goes through Open WebUI; picked from
29
- `openrouter (direct)`, it goes direct. Nothing here calls Open WebUI on its
30
- own: the passthrough is reached only through an endpoint's own `decisions`
31
- URL, which nothing writes (an Open WebUI hub is given one by hand), and the
32
- onboarding hints about it show only for an Open WebUI hub (endpoints.hub). OpenRouter's /models omits its decision
33
- models, so that endpoint names them in `unlisted_models` to make them pickable.
34
-
35
- WHAT EACH MODEL CAN ANSWER
36
- --------------------------
37
- `question_types` lists the types a model accepted when onboarding probed it
38
- with one question of each: Mercury Decide takes noul, choice and score;
39
- Respan takes noul only, with plain-string instructions and criteria. `ask`
40
- refuses a type the entry does not list, so a caller learns it from the bench
41
- rather than from a 400. Both providers take the same body (`model`, `state`,
42
- `questions`) and return the same `answers`, so one client covers them.
43
- """
44
-
45
- from __future__ import annotations
46
-
47
- import argparse
48
- import json
49
- import pathlib
50
- import sys
51
- import time
52
- import urllib.error
53
- import urllib.request
54
-
55
- ROOT = pathlib.Path(__file__).resolve().parent.parent
56
- from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
57
- FILE = DATA / "config" / "decision-models.json"
58
- sys.path.insert(0, str(ROOT / "metrics"))
59
- import endpoints # noqa: E402
60
-
61
- # One question of each type, plain strings throughout so Respan's rule holds.
62
- PROBES = {
63
- "noul": {"type": "noul", "instructions": "Is this message about billing?",
64
- "criteria": {"true": "It is about charges, payments or refunds.",
65
- "false": "It is about something else."}},
66
- "choice": {"type": "choice", "instructions": "Which team should handle this message?",
67
- "criteria": {"billing": "Charges, payments, refunds.",
68
- "support": "Product problems.", "sales": "Buying new things."}},
69
- "score": {"type": "score", "instructions": "How urgent is this message?",
70
- "criteria": ["Can wait", "Needs attention soon", "Needs immediate attention"]},
71
- }
72
- PROBE_STATE = "Please refund the duplicate charge on my account today."
73
- # Providers name the same API differently: Inception and OpenRouter
74
- # `decisions`, Ollama `systemone`. An endpoint URL holding `{path}` (Open
75
- # WebUI's passthrough, which appends it to the connection's base) is tried
76
- # with each, and the one that answered is recorded on the model as `path`.
77
- PATHS = ("decisions", "systemone")
78
-
79
-
80
- def load() -> dict[str, dict]:
81
- try:
82
- return json.loads(FILE.read_text(encoding="utf-8"))
83
- except (OSError, ValueError):
84
- return {}
85
-
86
-
87
- def call(endpoint: str, served: str, state, questions: dict, timeout: int = 60,
88
- path: str = "decisions") -> dict:
89
- """POST to an endpoint's decisions URL. Returns the parsed body; raises on HTTP errors."""
90
- entry = endpoints.load()[endpoint]
91
- url = (entry.get("decisions") or "").replace("{path}", path)
92
- if not url:
93
- raise ValueError(f"{endpoint} has no decisions URL in config/endpoints.json")
94
- headers = {"Content-Type": "application/json", **(entry.get("headers") or {})}
95
- if (secret := endpoints.key(endpoint)):
96
- headers["Authorization"] = f"Bearer {secret}"
97
- body = json.dumps({"model": served, "state": state, "questions": questions}).encode()
98
- # Inception answers 429 to calls in quick succession (seen 2026-10-16:
99
- # the second of three onboarding probes), so a 429 waits and retries.
100
- for wait in (5, 15, 0):
101
- request = urllib.request.Request(url, data=body, headers=headers, method="POST")
102
- try:
103
- with urllib.request.urlopen(request, timeout=timeout) as response:
104
- return json.load(response)
105
- except urllib.error.HTTPError as exc:
106
- if exc.code != 429 or not wait:
107
- raise
108
- time.sleep(wait)
109
-
110
-
111
- def error_text(exc: Exception) -> str:
112
- if isinstance(exc, urllib.error.HTTPError):
113
- try:
114
- detail = json.loads(exc.read() or b"{}")
115
- except ValueError:
116
- detail = ""
117
- # {"error": {"message": ...}}, {"error": "..."}, {"detail": ...}: providers differ.
118
- if isinstance(detail, dict):
119
- detail = detail.get("error") or detail
120
- if isinstance(detail, dict):
121
- detail = detail.get("message") or detail
122
- return f"{exc.code} {str(detail)[:160]}".strip()
123
- return f"{type(exc).__name__}: {exc}"
124
-
125
-
126
- def probe(endpoint: str, served: str) -> tuple[str | None, str]:
127
- """(None, path) if `endpoint`'s decisions URL answers a yes/no question for
128
- `served`, else (why not, ""). Each of PATHS is tried where the URL has `{path}`."""
129
- url = endpoints.load()[endpoint].get("decisions")
130
- if not url:
131
- return f"{endpoint} has no decisions URL in config/endpoints.json", ""
132
- why = []
133
- for path in (PATHS if "{path}" in url else PATHS[:1]):
134
- try:
135
- answer = call(endpoint, served, PROBE_STATE, {"probe": PROBES["noul"]}, path=path)
136
- except Exception as exc: # noqa: BLE001
137
- why.append(f"{endpoint} /{path}: {error_text(exc)}")
138
- continue
139
- if "probe" in answer.get("answers", {}):
140
- return None, path
141
- why.append(f"{endpoint} /{path}: no answer in {str(answer)[:120]}")
142
- return "; ".join(why), ""
143
-
144
-
145
- def refuses_chat(endpoint: str, served: str) -> bool:
146
- """True if a one-token chat call is refused because the model is a decision
147
- model ("is a decisions model and cannot be used with the chat/completions
148
- endpoint", as OpenRouter words it, relayed by Open WebUI)."""
149
- entry = endpoints.load()[endpoint]
150
- headers = {"Content-Type": "application/json", **(entry.get("headers") or {})}
151
- if (secret := endpoints.key(endpoint)):
152
- headers["Authorization"] = f"Bearer {secret}"
153
- body = json.dumps({"model": served, "max_tokens": 1,
154
- "messages": [{"role": "user", "content": "ready?"}]}).encode()
155
- request = urllib.request.Request(endpoints.base(endpoint) + "/chat/completions",
156
- data=body, headers=headers, method="POST")
157
- try:
158
- with urllib.request.urlopen(request, timeout=60):
159
- return False
160
- except urllib.error.HTTPError as exc:
161
- # OpenRouter says "is a decisions model"; other providers word it their
162
- # own way, so any refusal that names decisions or systemone counts.
163
- text = error_text(exc).lower()
164
- return any(w in text for w in ("decisions model", "decision model", "systemone", "/decisions"))
165
- except Exception: # noqa: BLE001
166
- return False
167
-
168
-
169
- def question_types(endpoint: str, served: str, path: str = "decisions") -> list[str]:
170
- """Which question types the model accepts, one probe call per type.
171
-
172
- noul is taken as proven: `probe` has just had one answered, and asking
173
- again only lets a transient 429 record `[]`, which `ask` would then read
174
- as "takes nothing"."""
175
- ok = ["noul"]
176
- for kind, question in PROBES.items():
177
- if kind in ok:
178
- continue
179
- try:
180
- if kind in call(endpoint, served, PROBE_STATE, {kind: question}, path=path).get("answers", {}):
181
- ok.append(kind)
182
- except Exception: # noqa: BLE001
183
- pass
184
- return ok
185
-
186
-
187
- def ask(alias: str, state, questions: dict) -> dict:
188
- entry = load()[alias]
189
- wrong = sorted({q["type"] for q in questions.values()} - set(entry["question_types"]))
190
- if wrong:
191
- raise ValueError(f"{alias} does not take {', '.join(wrong)} questions;"
192
- f" it takes {', '.join(entry['question_types'])}")
193
- return call(entry["via"], entry["served"], state, questions, path=entry.get("path", "decisions"))
194
-
195
-
196
- def main() -> None:
197
- sys.stdout.reconfigure(encoding="utf-8", errors="replace")
198
- ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
199
- ap.add_argument("alias", help="a key of config/decision-models.json")
200
- ap.add_argument("state", help="the text being judged")
201
- group = ap.add_mutually_exclusive_group(required=True)
202
- group.add_argument("--noul", metavar="QUESTION", help="a yes/no question")
203
- group.add_argument("--choice", metavar="A,B,C", help="comma-separated options")
204
- group.add_argument("--score", metavar="L0,L1,L2", help="comma-separated levels, lowest first")
205
- ap.add_argument("--instructions", default="Answer the question about this text.")
206
- args = ap.parse_args()
207
- if args.alias not in load():
208
- sys.exit(f"{args.alias} is not in {FILE.name}; onboard it first: scripts/onboard.py <served id>")
209
- if args.noul:
210
- q = {"type": "noul", "instructions": args.noul,
211
- "criteria": {"true": "Yes.", "false": "No."}}
212
- elif args.choice:
213
- q = {"type": "choice", "instructions": args.instructions,
214
- "criteria": {o.strip(): None for o in args.choice.split(",")}}
215
- else:
216
- q = {"type": "score", "instructions": args.instructions,
217
- "criteria": [o.strip() for o in args.score.split(",")]}
218
- try:
219
- print(json.dumps(ask(args.alias, args.state, {"q": q})["answers"]["q"], indent=1))
220
- except ValueError as exc:
221
- sys.exit(str(exc))
222
- except Exception as exc: # noqa: BLE001
223
- sys.exit(error_text(exc))
224
-
225
-
226
- if __name__ == "__main__":
227
- 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 picked from when onboarding, recorded
28
+ as `via`: picked from Open WebUI, it goes through Open WebUI; picked from
29
+ `openrouter (direct)`, it goes direct. Nothing here calls Open WebUI on its
30
+ own: the passthrough is reached only through an endpoint's own `decisions`
31
+ URL, which nothing writes (an Open WebUI hub is given one by hand), and the
32
+ onboarding hints about it show only for an Open WebUI hub (endpoints.hub). OpenRouter's /models omits its decision
33
+ models, so that endpoint names them in `unlisted_models` to make them pickable.
34
+
35
+ WHAT EACH MODEL CAN ANSWER
36
+ --------------------------
37
+ `question_types` lists the types a model accepted when onboarding probed it
38
+ with one question of each: Mercury Decide takes noul, choice and score;
39
+ Respan takes noul only, with plain-string instructions and criteria. `ask`
40
+ refuses a type the entry does not list, so a caller learns it from the bench
41
+ rather than from a 400. Both providers take the same body (`model`, `state`,
42
+ `questions`) and return the same `answers`, so one client covers them.
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import argparse
48
+ import json
49
+ import pathlib
50
+ import sys
51
+ import time
52
+ import urllib.error
53
+ import urllib.request
54
+
55
+ ROOT = pathlib.Path(__file__).resolve().parent.parent
56
+ from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
57
+ FILE = DATA / "config" / "decision-models.json"
58
+ sys.path.insert(0, str(ROOT / "metrics"))
59
+ import endpoints # noqa: E402
60
+
61
+ # One question of each type, plain strings throughout so Respan's rule holds.
62
+ PROBES = {
63
+ "noul": {"type": "noul", "instructions": "Is this message about billing?",
64
+ "criteria": {"true": "It is about charges, payments or refunds.",
65
+ "false": "It is about something else."}},
66
+ "choice": {"type": "choice", "instructions": "Which team should handle this message?",
67
+ "criteria": {"billing": "Charges, payments, refunds.",
68
+ "support": "Product problems.", "sales": "Buying new things."}},
69
+ "score": {"type": "score", "instructions": "How urgent is this message?",
70
+ "criteria": ["Can wait", "Needs attention soon", "Needs immediate attention"]},
71
+ }
72
+ PROBE_STATE = "Please refund the duplicate charge on my account today."
73
+ # Providers name the same API differently: Inception and OpenRouter
74
+ # `decisions`, Ollama `systemone`. An endpoint URL holding `{path}` (Open
75
+ # WebUI's passthrough, which appends it to the connection's base) is tried
76
+ # with each, and the one that answered is recorded on the model as `path`.
77
+ PATHS = ("decisions", "systemone")
78
+
79
+
80
+ def load() -> dict[str, dict]:
81
+ try:
82
+ return json.loads(FILE.read_text(encoding="utf-8"))
83
+ except (OSError, ValueError):
84
+ return {}
85
+
86
+
87
+ def call(endpoint: str, served: str, state, questions: dict, timeout: int = 60,
88
+ path: str = "decisions") -> dict:
89
+ """POST to an endpoint's decisions URL. Returns the parsed body; raises on HTTP errors."""
90
+ entry = endpoints.load()[endpoint]
91
+ url = (entry.get("decisions") or "").replace("{path}", path)
92
+ if not url:
93
+ raise ValueError(f"{endpoint} has no decisions URL in config/endpoints.json")
94
+ headers = {"Content-Type": "application/json", **(entry.get("headers") or {})}
95
+ if (secret := endpoints.key(endpoint)):
96
+ headers["Authorization"] = f"Bearer {secret}"
97
+ body = json.dumps({"model": served, "state": state, "questions": questions}).encode()
98
+ # Inception answers 429 to calls in quick succession (seen 2026-10-16:
99
+ # the second of three onboarding probes), so a 429 waits and retries.
100
+ for wait in (5, 15, 0):
101
+ request = urllib.request.Request(url, data=body, headers=headers, method="POST")
102
+ try:
103
+ with urllib.request.urlopen(request, timeout=timeout) as response:
104
+ return json.load(response)
105
+ except urllib.error.HTTPError as exc:
106
+ if exc.code != 429 or not wait:
107
+ raise
108
+ time.sleep(wait)
109
+
110
+
111
+ def error_text(exc: Exception) -> str:
112
+ if isinstance(exc, urllib.error.HTTPError):
113
+ try:
114
+ detail = json.loads(exc.read() or b"{}")
115
+ except ValueError:
116
+ detail = ""
117
+ # {"error": {"message": ...}}, {"error": "..."}, {"detail": ...}: providers differ.
118
+ if isinstance(detail, dict):
119
+ detail = detail.get("error") or detail
120
+ if isinstance(detail, dict):
121
+ detail = detail.get("message") or detail
122
+ return f"{exc.code} {str(detail)[:160]}".strip()
123
+ return f"{type(exc).__name__}: {exc}"
124
+
125
+
126
+ def probe(endpoint: str, served: str) -> tuple[str | None, str]:
127
+ """(None, path) if `endpoint`'s decisions URL answers a yes/no question for
128
+ `served`, else (why not, ""). Each of PATHS is tried where the URL has `{path}`."""
129
+ url = endpoints.load()[endpoint].get("decisions")
130
+ if not url:
131
+ return f"{endpoint} has no decisions URL in config/endpoints.json", ""
132
+ why = []
133
+ for path in (PATHS if "{path}" in url else PATHS[:1]):
134
+ try:
135
+ answer = call(endpoint, served, PROBE_STATE, {"probe": PROBES["noul"]}, path=path)
136
+ except Exception as exc: # noqa: BLE001
137
+ why.append(f"{endpoint} /{path}: {error_text(exc)}")
138
+ continue
139
+ if "probe" in answer.get("answers", {}):
140
+ return None, path
141
+ why.append(f"{endpoint} /{path}: no answer in {str(answer)[:120]}")
142
+ return "; ".join(why), ""
143
+
144
+
145
+ def refuses_chat(endpoint: str, served: str) -> bool:
146
+ """True if a one-token chat call is refused because the model is a decision
147
+ model ("is a decisions model and cannot be used with the chat/completions
148
+ endpoint", as OpenRouter words it, relayed by Open WebUI)."""
149
+ entry = endpoints.load()[endpoint]
150
+ headers = {"Content-Type": "application/json", **(entry.get("headers") or {})}
151
+ if (secret := endpoints.key(endpoint)):
152
+ headers["Authorization"] = f"Bearer {secret}"
153
+ body = json.dumps({"model": served, "max_tokens": 1,
154
+ "messages": [{"role": "user", "content": "ready?"}]}).encode()
155
+ request = urllib.request.Request(endpoints.base(endpoint) + "/chat/completions",
156
+ data=body, headers=headers, method="POST")
157
+ try:
158
+ with urllib.request.urlopen(request, timeout=60):
159
+ return False
160
+ except urllib.error.HTTPError as exc:
161
+ # OpenRouter says "is a decisions model"; other providers word it their
162
+ # own way, so any refusal that names decisions or systemone counts.
163
+ text = error_text(exc).lower()
164
+ return any(w in text for w in ("decisions model", "decision model", "systemone", "/decisions"))
165
+ except Exception: # noqa: BLE001
166
+ return False
167
+
168
+
169
+ def question_types(endpoint: str, served: str, path: str = "decisions") -> list[str]:
170
+ """Which question types the model accepts, one probe call per type.
171
+
172
+ noul is taken as proven: `probe` has just had one answered, and asking
173
+ again only lets a transient 429 record `[]`, which `ask` would then read
174
+ as "takes nothing"."""
175
+ ok = ["noul"]
176
+ for kind, question in PROBES.items():
177
+ if kind in ok:
178
+ continue
179
+ try:
180
+ if kind in call(endpoint, served, PROBE_STATE, {kind: question}, path=path).get("answers", {}):
181
+ ok.append(kind)
182
+ except Exception: # noqa: BLE001
183
+ pass
184
+ return ok
185
+
186
+
187
+ def ask(alias: str, state, questions: dict) -> dict:
188
+ entry = load()[alias]
189
+ wrong = sorted({q["type"] for q in questions.values()} - set(entry["question_types"]))
190
+ if wrong:
191
+ raise ValueError(f"{alias} does not take {', '.join(wrong)} questions;"
192
+ f" it takes {', '.join(entry['question_types'])}")
193
+ return call(entry["via"], entry["served"], state, questions, path=entry.get("path", "decisions"))
194
+
195
+
196
+ def main() -> None:
197
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
198
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
199
+ ap.add_argument("alias", help="a key of config/decision-models.json")
200
+ ap.add_argument("state", help="the text being judged")
201
+ group = ap.add_mutually_exclusive_group(required=True)
202
+ group.add_argument("--noul", metavar="QUESTION", help="a yes/no question")
203
+ group.add_argument("--choice", metavar="A,B,C", help="comma-separated options")
204
+ group.add_argument("--score", metavar="L0,L1,L2", help="comma-separated levels, lowest first")
205
+ ap.add_argument("--instructions", default="Answer the question about this text.")
206
+ args = ap.parse_args()
207
+ if args.alias not in load():
208
+ sys.exit(f"{args.alias} is not in {FILE.name}; onboard it first: scripts/onboard.py <served id>")
209
+ if args.noul:
210
+ q = {"type": "noul", "instructions": args.noul,
211
+ "criteria": {"true": "Yes.", "false": "No."}}
212
+ elif args.choice:
213
+ q = {"type": "choice", "instructions": args.instructions,
214
+ "criteria": {o.strip(): None for o in args.choice.split(",")}}
215
+ else:
216
+ q = {"type": "score", "instructions": args.instructions,
217
+ "criteria": [o.strip() for o in args.score.split(",")]}
218
+ try:
219
+ print(json.dumps(ask(args.alias, args.state, {"q": q})["answers"]["q"], indent=1))
220
+ except ValueError as exc:
221
+ sys.exit(str(exc))
222
+ except Exception as exc: # noqa: BLE001
223
+ sys.exit(error_text(exc))
224
+
225
+
226
+ if __name__ == "__main__":
227
+ main()