@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.
- package/AGENTS.md +147 -147
- package/BENCHMARKS.md +262 -262
- package/GATEWAY.md +272 -271
- package/NOTICE +28 -28
- package/PRIVACY.md +46 -46
- package/README.md +368 -275
- package/SECURITY.md +83 -83
- package/TERMS.md +22 -22
- package/USAGE.md +170 -155
- package/VERSION +1 -1
- package/bin/benchwarmers.js +16 -72
- package/cli.py +666 -0
- package/examples/config/gateway.json +5 -5
- package/examples/config/model-roles.json +11 -11
- package/gateway.py +755 -712
- package/launcher.py +120 -0
- package/mcp_server.py +266 -0
- package/metrics/alerts.py +116 -116
- package/metrics/aliases.py +41 -41
- package/metrics/bench_collections.py +141 -141
- package/metrics/catalog.py +295 -295
- package/metrics/client.py +394 -373
- package/metrics/cliproxy.py +191 -191
- package/metrics/connect.py +691 -691
- package/metrics/cost.py +152 -152
- package/metrics/decide.py +227 -227
- package/metrics/discover.py +318 -318
- package/metrics/endpoints.py +436 -436
- package/metrics/guard.py +617 -592
- package/metrics/learn.py +221 -221
- package/metrics/litellm_hub.py +206 -206
- package/metrics/model_roles.py +84 -84
- package/metrics/offload.py +166 -166
- package/metrics/owui.py +205 -205
- package/metrics/paths.py +60 -60
- package/metrics/route.py +579 -574
- package/metrics/scheduler.py +275 -275
- package/metrics/sources.py +167 -167
- package/metrics/wrong.py +144 -144
- package/package.json +68 -64
- package/perf/README.md +89 -89
- package/perf/run.py +261 -261
- package/scripts/auto_retire.py +367 -367
- package/scripts/check.py +108 -104
- package/scripts/check_deprecations.py +378 -378
- package/scripts/check_themes.py +103 -103
- package/scripts/init.py +124 -124
- package/scripts/list_served.py +78 -78
- package/scripts/notice.py +78 -78
- package/scripts/onboard.py +401 -331
- package/scripts/provider_tags.py +129 -129
- package/scripts/reprefix.py +136 -136
- package/scripts/sync_config.py +105 -105
- package/scripts/test_auth.py +255 -255
- package/scripts/test_cli.py +189 -0
- package/scripts/test_client.py +233 -233
- package/scripts/test_connect.py +222 -222
- package/scripts/test_first_run.py +180 -180
- package/scripts/test_hubs.py +328 -328
- package/scripts/test_launcher.py +67 -0
- package/scripts/test_llm_log.py +85 -0
- package/scripts/test_mcp.py +178 -0
- package/scripts/test_merged.py +214 -193
- package/scripts/test_no_owui.py +291 -291
- package/scripts/test_owui.py +90 -90
- package/scripts/test_serve_edit.py +448 -386
- package/scripts/test_verdict.py +98 -98
- package/scripts/test_vision.py +228 -228
- package/serve.py +3904 -3662
- package/suite/mine.py +154 -154
- package/suite/pace.py +145 -145
- package/suite/run.py +1046 -1040
- package/suite/seed.py +421 -421
- package/suite/tasks/claim-alias-unbound/fixtures/onboard.py +286 -286
- package/suite/tasks/claim-error-text-string/fixtures/decide.py +187 -187
- package/suite/tasks/claim-list-body/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-member-nominated-twice/fixtures/gateway.py +284 -284
- package/suite/tasks/claim-nominee-uses-slot/fixtures/gateway.py +284 -284
- package/suite/tasks/claim-silent-exit/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-sse-hang/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-unknown-nominee-crash/fixtures/gateway.py +284 -284
- package/suite/tasks/rebench-matchms-809-bug/task.json +23 -23
- package/suite/tasks/rebench-matchms-809-control/task.json +27 -27
- package/suite/tasks/rebench-more-itertools-1028-bug/task.json +23 -23
- package/suite/tasks/rebench-more-itertools-1028-control/task.json +27 -27
- package/suite/tasks/rebench-narwhals-2864-bug/task.json +23 -23
- package/suite/tasks/rebench-narwhals-2864-control/task.json +27 -27
- package/suite/tasks/rebench-quantecon-py-769-bug/task.json +23 -23
- package/suite/tasks/rebench-quantecon-py-769-control/task.json +27 -27
- package/suite/tasks/rebench-scrapy-6867-bug/task.json +23 -23
- package/suite/tasks/rebench-scrapy-6867-control/task.json +27 -27
- package/ui/bench.css +430 -424
- package/ui/bench.js +1756 -1555
- package/ui/themes/dark.css +35 -35
- package/ui/themes/hardwood.css +52 -52
- 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()
|