@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
|
@@ -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()
|