@wendellresearch-ai/benchwarmers 0.5.5 → 0.6.0
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/.github/logo-dark.svg +49 -0
- package/.github/logo.svg +49 -0
- package/AGENTS.md +4 -1
- package/BENCHMARKS.md +160 -0
- package/GATEWAY.md +59 -12
- package/PRIVACY.md +25 -6
- package/README.md +62 -15
- package/SECURITY.md +20 -9
- package/USAGE.md +87 -14
- package/VERSION +1 -1
- package/cli.py +12 -1
- package/gateway.py +122 -91
- package/mcp_server.py +2 -1
- package/metrics/alerts.py +25 -0
- package/metrics/bench_collections.py +58 -9
- package/metrics/catalog.py +54 -9
- package/metrics/client.py +51 -1
- package/metrics/connect.py +225 -0
- package/metrics/decide.py +690 -27
- package/metrics/discover.py +191 -11
- package/metrics/endpoints.py +4 -6
- package/metrics/guard.py +6 -4
- package/metrics/ledger.py +3 -1
- package/metrics/locked.py +102 -1
- package/metrics/offload.py +36 -19
- package/metrics/owui.py +26 -0
- package/metrics/route.py +19 -4
- package/metrics/scheduler.py +11 -1
- package/metrics/sources.py +15 -2
- package/metrics/test_decide.py +275 -7
- package/metrics/wrong.py +21 -9
- package/package.json +3 -1
- package/scripts/auto_retire.py +96 -21
- package/scripts/onboard.py +44 -3
- package/scripts/test_connect.py +152 -3
- package/scripts/test_first_run.py +4 -2
- package/scripts/test_formats.py +100 -1
- package/scripts/test_serve_edit.py +327 -4
- package/serve.py +1746 -170
- package/suite/README.md +5 -2
- package/suite/run.py +226 -6
- package/suite/test_pick.py +15 -0
- package/suite/test_score.py +82 -1
- package/ui/bench.css +44 -0
- package/ui/bench.js +940 -207
package/gateway.py
CHANGED
|
@@ -11,9 +11,13 @@ is called every day:
|
|
|
11
11
|
|
|
12
12
|
Collections and those limits live in `config/collections.json` and
|
|
13
13
|
`config/gateway.json` (see `metrics/bench_collections.py`), edited from the
|
|
14
|
-
dashboard. There is no default
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
dashboard. There is no default. A request that names no collection gets one
|
|
15
|
+
a decision model chooses from the collections' descriptions and tags, when
|
|
16
|
+
it is sure (pick_collection; gateway.json `pick_collection`, on unless
|
|
17
|
+
false), and the reply says which and why (`bench.picked`, X-Bench-Picked).
|
|
18
|
+
Otherwise, and for a collection that does not exist, it is refused with
|
|
19
|
+
every collection's description and tags, so the caller can choose — `GET
|
|
20
|
+
/v1/models` carries the same.
|
|
17
21
|
|
|
18
22
|
The reply is every answer, each labelled by seat and model with a call id,
|
|
19
23
|
including seats that failed or came back empty: who answered is evidence about
|
|
@@ -30,8 +34,9 @@ one-line note: the panel the caller waited for is never lost.
|
|
|
30
34
|
|
|
31
35
|
A collection's `shape` (review, design, …) decides which models may fill the
|
|
32
36
|
explorer seats. Left empty, a decision model labels the prompt when one that
|
|
33
|
-
takes choice questions is onboarded (not ad hoc)
|
|
34
|
-
|
|
37
|
+
takes choice questions is onboarded (not ad hoc): the one metrics/decide.py
|
|
38
|
+
choose() names for the shape use; otherwise, or on any doubt, the shape is
|
|
39
|
+
`general`. A request body may add `"with": ["<alias>"]` to seat a
|
|
35
40
|
model in an explorer chair, as `route.py --with` does.
|
|
36
41
|
|
|
37
42
|
A retired model (`config/retired.json`) is never called: retiring takes it out
|
|
@@ -155,19 +160,10 @@ import model_roles # noqa: E402
|
|
|
155
160
|
import offload # noqa: E402
|
|
156
161
|
import route # noqa: E402
|
|
157
162
|
|
|
158
|
-
# What the shape picker
|
|
159
|
-
#
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
"general": "A general question or task that fits none of the other kinds.",
|
|
163
|
-
"design": "Architecture, process or approach decisions: weighing options, an ADR, a plan.",
|
|
164
|
-
"prose": "Writing or editing documentation or other text.",
|
|
165
|
-
"review": "Judging whether given code is correct: finding bugs, or where a guarantee breaks.",
|
|
166
|
-
"code": "Writing new code or changing existing code.",
|
|
167
|
-
"enumeration": "Listing every place something happens in given code or files.",
|
|
168
|
-
"quick": "A short, bounded task: classify, extract or summarise a small input.",
|
|
169
|
-
}
|
|
170
|
-
CONFIDENT = 0.6
|
|
163
|
+
# What the shape picker chooses between, and how sure it must be: in
|
|
164
|
+
# metrics/decide.py, whose benchmark asks the same question.
|
|
165
|
+
SHAPES = decide.SHAPES
|
|
166
|
+
CONFIDENT = decide.CONFIDENT
|
|
171
167
|
LARGE = 8000 # characters; route.py keeps models that fail big prompts off big tasks
|
|
172
168
|
# Bytes. A million-token prompt is about 4 MB, and a photo sent as a data URL
|
|
173
169
|
# is 4/3 of its file. 20 MB is the tightest whole-request limit among the big
|
|
@@ -177,9 +173,9 @@ TOO_BIG = (" Send images as http(s) URLs, or smaller ones: models scale a photo
|
|
|
177
173
|
" 1,500-2,000 pixels on its long side anyway.")
|
|
178
174
|
IMAGE_TOKENS = 2_500 # per image, in the size check (estimate_tokens says why)
|
|
179
175
|
IMAGE_URL = re.compile(r"(https?://[^\s/?#]+|data:image/[\w.+-]+;base64,)", re.I)
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
176
|
+
# How an image or a URL is written down: in metrics/client.py, which writes a
|
|
177
|
+
# failed call's error to the ledger and so redacts it too.
|
|
178
|
+
from client import placeholder, redact # noqa: E402
|
|
183
179
|
HEADER_NAMES = re.compile(r"[A-Za-z0-9-]{1,64}(\s*,\s*[A-Za-z0-9-]{1,64}){0,31}")
|
|
184
180
|
SEAT_KEYS = ("call", "seat", "alias", "status", "seconds", "answer", "error")
|
|
185
181
|
QUERY_KEY = re.compile(r"([?&](?:key|api_key|access_token)=)[^&\s'\"]*", re.I)
|
|
@@ -243,32 +239,6 @@ def images(messages: list[dict]) -> list[str]:
|
|
|
243
239
|
return urls
|
|
244
240
|
|
|
245
241
|
|
|
246
|
-
def placeholder(url: str) -> str:
|
|
247
|
-
"""How an image is written down (the call log, offloads, the dashboard,
|
|
248
|
-
error text, the merger's copy of the request), never its bytes: a data
|
|
249
|
-
URL by its size, an http URL without its query (a signed URL's signature
|
|
250
|
-
is a credential) or a user:password."""
|
|
251
|
-
if url[:5].lower() == "data:":
|
|
252
|
-
return f"[image: data URL, {(len(url) * 3 // 4 + 999) // 1000:,} KB]"
|
|
253
|
-
u = urllib.parse.urlsplit(url)
|
|
254
|
-
return f"[image: {u.scheme}://{u.netloc.rpartition('@')[2]}{u.path[:200]}{'?...' if u.query else ''}]"
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
def redact(text: str) -> str:
|
|
258
|
-
"""`text` with each inline image as its placeholder, every http(s) URL
|
|
259
|
-
without its query or user:password, and any other long base64 run as its
|
|
260
|
-
size: an upstream may echo the request in an error, in whatever form, and
|
|
261
|
-
errors are logged, shown and learned from."""
|
|
262
|
-
def bare(m):
|
|
263
|
-
try:
|
|
264
|
-
u = urllib.parse.urlsplit(m.group())
|
|
265
|
-
except ValueError: # an unparseable netloc, such as "[::1"
|
|
266
|
-
return "[url]"
|
|
267
|
-
return f"{u.scheme}://{u.netloc.rpartition('@')[2]}{u.path}{'?...' if u.query else ''}"
|
|
268
|
-
text = URLS.sub(bare, DATA_URLS.sub(lambda m: placeholder(m.group()), text))
|
|
269
|
-
return BASE64.sub(lambda m: f"[base64, {(len(m.group()) * 3 // 4 + 999) // 1000:,} KB]", text)
|
|
270
|
-
|
|
271
|
-
|
|
272
242
|
def cannot_see(alias: str) -> str:
|
|
273
243
|
"""Why an image cannot be put to `alias`, or "" when it can. The catalog
|
|
274
244
|
decides (metrics/catalog.py: the model's input types, your Vision / OCR
|
|
@@ -289,29 +259,45 @@ def cannot_see(alias: str) -> str:
|
|
|
289
259
|
|
|
290
260
|
|
|
291
261
|
def pick_shape(prompt: str) -> tuple[str, str]:
|
|
292
|
-
"""(shape, how it was chosen).
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
if not picker:
|
|
301
|
-
return "general", "no decision model onboarded"
|
|
302
|
-
question = {"shape": {"type": "choice",
|
|
303
|
-
"instructions": "What kind of task does this prompt ask for?",
|
|
304
|
-
"criteria": SHAPES}}
|
|
305
|
-
try:
|
|
306
|
-
answer = decide.ask(picker, prompt[:20000], question)["answers"]["shape"]
|
|
307
|
-
except Exception as exc: # noqa: BLE001
|
|
308
|
-
return "general", f"{picker} failed ({decide.error_text(exc)[:80]})"
|
|
309
|
-
conf = answer.get("confidence", 0)
|
|
262
|
+
"""(shape, how it was chosen). The decision model decide.choose names for
|
|
263
|
+
the shape use is asked (ranked on the collection set, never a `:free` one
|
|
264
|
+
while another answers; the first onboarded before any is ranked), and the
|
|
265
|
+
call is logged. Falls back to general on any doubt."""
|
|
266
|
+
answer, picker, why = decide.pick("shape", prompt, SHAPES, "pick_shape")
|
|
267
|
+
if why:
|
|
268
|
+
return "general", why
|
|
269
|
+
conf = float(answer.get("confidence") or 0)
|
|
310
270
|
if answer.get("choice") not in SHAPES or conf < CONFIDENT:
|
|
311
271
|
return "general", f"{picker} unsure ({answer.get('choice')} at {conf:.2f})"
|
|
312
272
|
return answer["choice"], f"{picker}, {conf:.2f}"
|
|
313
273
|
|
|
314
274
|
|
|
275
|
+
def pick_collection(messages: list[dict], who: str) -> tuple[str | None, dict]:
|
|
276
|
+
"""For a request that names no collection: (the collection a decision
|
|
277
|
+
model chose for it, what the reply says about that), or (None, {}) when
|
|
278
|
+
gateway.json's `pick_collection` is off, nothing is on offer, or the model
|
|
279
|
+
is not sure (its confidence under `pick_confidence`, else CONFIDENT); the
|
|
280
|
+
request is then refused as before. The model best on the benchmark's
|
|
281
|
+
collection set chooses among the collections this key may call that are
|
|
282
|
+
not ad hoc, from their descriptions and tags, as choose_message lists them."""
|
|
283
|
+
s = bench_collections.pick_settings()
|
|
284
|
+
cols = {n: c for n, c in bench_collections.load().items() if not c.get("ad_hoc") and guard.may_call(who, n)}
|
|
285
|
+
if not s["pick_collection"] or not cols:
|
|
286
|
+
return None, {}
|
|
287
|
+
system, prompt = flatten(messages)
|
|
288
|
+
whole = decide.request_text({"system": system, "prompt": prompt}) # as the benchmark asks it
|
|
289
|
+
if not whole:
|
|
290
|
+
return None, {}
|
|
291
|
+
answer, by, why = decide.pick("collection", whole, decide.collection_options(cols), "pick_collection")
|
|
292
|
+
conf = float(answer.get("confidence") or 0)
|
|
293
|
+
if why or answer.get("choice") not in cols or conf < (s["pick_confidence"] or CONFIDENT):
|
|
294
|
+
return None, {}
|
|
295
|
+
name = answer["choice"]
|
|
296
|
+
return name, {"collection": bench_collections.PREFIX + name, "by": by, "confidence": round(conf, 2),
|
|
297
|
+
"why": f"no collection was named, so {by} chose it from the collections' descriptions and"
|
|
298
|
+
f" tags, at {conf:.2f}"}
|
|
299
|
+
|
|
300
|
+
|
|
315
301
|
def retired() -> dict[str, dict]:
|
|
316
302
|
"""config/retired.json: alias -> its record (when, why). Retiring a model
|
|
317
303
|
takes it out of every collection (serve.remove_model); this is for a
|
|
@@ -333,10 +319,21 @@ def retired_why(entry: dict) -> str:
|
|
|
333
319
|
def refused_now(aliases, est_tokens: int, within: float) -> dict[str, str]:
|
|
334
320
|
"""alias -> why, for models whose known rate limits or quota leave no room
|
|
335
321
|
for a call within `within` seconds (metrics/pacing.held): asked, they
|
|
336
|
-
would only bring back the bench's own refusal, so they are not seated.
|
|
322
|
+
would only bring back the bench's own refusal, so they are not seated.
|
|
323
|
+
So are models on a connection the alerts say has stopped answering: the
|
|
324
|
+
daily probes still try it, and the first answer ends the alert."""
|
|
337
325
|
import pacing
|
|
338
326
|
out = {}
|
|
327
|
+
try:
|
|
328
|
+
import alerts
|
|
329
|
+
down = {m: a.get("message") or "its connection is not answering"
|
|
330
|
+
for a in alerts.active().values() if a.get("kind") == "connection" for m in a.get("aliases") or []}
|
|
331
|
+
except Exception: # noqa: BLE001 -- a broken alert file is not a refusal
|
|
332
|
+
down = {}
|
|
339
333
|
for a in aliases:
|
|
334
|
+
if a in down:
|
|
335
|
+
out[a] = down[a]
|
|
336
|
+
continue
|
|
340
337
|
try:
|
|
341
338
|
h = pacing.held(a, route.connection_of(a), est_tokens)
|
|
342
339
|
except Exception: # noqa: BLE001 -- a broken limit file is not a refusal
|
|
@@ -379,9 +376,10 @@ def ask_seat(alias: str, system: str, prompt: str, messages: list[dict] | None =
|
|
|
379
376
|
else:
|
|
380
377
|
answer, error = _ask_llm(cmd, prompt)
|
|
381
378
|
# A quota or credit notice delivered as the reply's text, with a success
|
|
382
|
-
# exit, is not an answer (suite/run.py PROVIDER_NOTICES
|
|
379
|
+
# exit, is not an answer (suite/run.py PROVIDER_NOTICES; a short reply
|
|
380
|
+
# that only looks like one is put to a decision model, once per shape).
|
|
383
381
|
from run import provider_notice
|
|
384
|
-
if not error and (notice := provider_notice(answer)):
|
|
382
|
+
if not error and (notice := provider_notice(answer, ask=True)):
|
|
385
383
|
error, answer = f"provider notice in a success reply ({notice}): {answer[:200]}", ""
|
|
386
384
|
if messages is not None:
|
|
387
385
|
# Before the error is cut, logged or learned from. ponytail: the answer
|
|
@@ -396,7 +394,8 @@ def ask_seat(alias: str, system: str, prompt: str, messages: list[dict] | None =
|
|
|
396
394
|
except Exception: # noqa: BLE001
|
|
397
395
|
pass
|
|
398
396
|
status = "error" if error else ("empty" if not answer else "ok")
|
|
399
|
-
|
|
397
|
+
# Its start and end: the start holds the HTTP status a long body's tail lost.
|
|
398
|
+
return {"status": status, "answer": answer, "error": client.clip(error),
|
|
400
399
|
"seconds": round(time.monotonic() - started, 1)}
|
|
401
400
|
|
|
402
401
|
|
|
@@ -544,10 +543,13 @@ def section(seat: dict) -> str:
|
|
|
544
543
|
|
|
545
544
|
|
|
546
545
|
def run_collection(name: str, messages: list[dict], nominated: list[str] | None = None,
|
|
547
|
-
reply: str = "", who: str = "local", on_part=None) -> tuple[str, dict]:
|
|
546
|
+
reply: str = "", who: str = "local", on_part=None, picked: dict | None = None) -> tuple[str, dict]:
|
|
548
547
|
"""The panel's reply as markdown, and the same seats as data for the
|
|
549
548
|
response's `bench` field. `reply`: the request's mode, "" for the
|
|
550
549
|
collection's own. `who`: the gateway key that asked, for the call log.
|
|
550
|
+
`picked`: the request named no collection and a decision model chose
|
|
551
|
+
this one (pick_collection); the reply says so, in `bench.picked` and its
|
|
552
|
+
heading, and the call log keeps it (so the benchmark never learns from it).
|
|
551
553
|
|
|
552
554
|
`on_part`, for a streaming caller: called with each piece of an
|
|
553
555
|
every-answer reply as it is ready, the heading once the panel is seated,
|
|
@@ -560,7 +562,7 @@ def run_collection(name: str, messages: list[dict], nominated: list[str] | None
|
|
|
560
562
|
if not prompt.strip():
|
|
561
563
|
raise ValueError("no user message to put to the collection")
|
|
562
564
|
collection = bench_collections.load()[name]
|
|
563
|
-
s = bench_collections.
|
|
565
|
+
s = bench_collections.limits(collection)
|
|
564
566
|
raw = collection.get("members") or []
|
|
565
567
|
# The editor dedupes and caps; a hand-edited file may not have.
|
|
566
568
|
members = list(dict.fromkeys(m for m in raw if isinstance(m, str) and m))
|
|
@@ -615,18 +617,22 @@ def run_collection(name: str, messages: list[dict], nominated: list[str] | None
|
|
|
615
617
|
# like the others; it would only come back as the refusal.
|
|
616
618
|
held = refused_now([*sitting, *nominated], needed, SEAT_TIMEOUT)
|
|
617
619
|
skipped += [{"seat": "member" if a in sitting else "with", "alias": a, "why": why} for a, why in held.items()]
|
|
620
|
+
# AND AN EXPLORER SITS IN FOR A HELD MEMBER, until its limit has room: the
|
|
621
|
+
# member stays on the collection, and this is decided per request. Each
|
|
622
|
+
# one is named on its seat. Max explorers 0 means only the members ever run.
|
|
623
|
+
lent = [a for a in sitting if a in held] if s["max_explorers"] else []
|
|
618
624
|
sitting = [a for a in sitting if a not in held]
|
|
619
625
|
nominated = [a for a in nominated if a not in held]
|
|
620
626
|
if pictures and not collection.get("shape"):
|
|
621
627
|
shape, how = "vision", "the request carries images"
|
|
622
|
-
elif not n_explorers:
|
|
628
|
+
elif not n_explorers and not lent:
|
|
623
629
|
shape, how = collection.get("shape") or "general", "no explorer seats to fill"
|
|
624
630
|
elif collection.get("shape"):
|
|
625
631
|
shape, how = collection["shape"], "set on the collection"
|
|
626
632
|
else:
|
|
627
633
|
shape, how = pick_shape(whole)
|
|
628
634
|
seats = [(f"member{i}", "member", alias) for i, alias in enumerate(sitting, 1)]
|
|
629
|
-
if n_explorers:
|
|
635
|
+
if n_explorers or lent:
|
|
630
636
|
# `with` in the request body is route.py's --with: an explorer seat.
|
|
631
637
|
from run import _registered_names
|
|
632
638
|
avoid = retiring() | set(too_small(needed, _registered_names())) # explorers must fit too
|
|
@@ -635,9 +641,11 @@ def run_collection(name: str, messages: list[dict], nominated: list[str] | None
|
|
|
635
641
|
if pictures:
|
|
636
642
|
avoid |= {a for a in _registered_names() if cannot_see(a)} # and see
|
|
637
643
|
p = route.panel(shape, large=len(whole) > LARGE, nominated=nominated or None,
|
|
638
|
-
members=sitting, explorers=n_explorers, avoid=avoid)
|
|
644
|
+
members=sitting, explorers=n_explorers, avoid=avoid, stand_ins=len(lent))
|
|
639
645
|
seats += [(key, label, p[key]["model"]) for key, label in
|
|
640
646
|
(("explorer", "explorer"), ("explorer2", "explorer 2")) if p.get(key)]
|
|
647
|
+
seats += [(f"standin{i}", f"stand-in for {member}", r["model"])
|
|
648
|
+
for i, (member, r) in enumerate(zip(lent, p["stand_ins"]), 1)]
|
|
641
649
|
if not seats and pictures:
|
|
642
650
|
raise ValueError(f"no seat in bench/{name} can see images, so none is asked rather than answer"
|
|
643
651
|
" blind: " + ("; ".join(f"{k['alias']} {k['why']}" for k in skipped) or "no members")
|
|
@@ -652,6 +660,8 @@ def run_collection(name: str, messages: list[dict], nominated: list[str] | None
|
|
|
652
660
|
task = " ".join(prompt.split())[:90] + f" ({model}, {shape})"
|
|
653
661
|
out = [f"Bench {model} {panel_id} · {len(sitting)} members + {len(seats) - len(sitting)}"
|
|
654
662
|
f" explorers · explorer shape {shape} ({how})\n"]
|
|
663
|
+
if picked:
|
|
664
|
+
out.append(f"Collection {model} chosen for this request: {picked['why']}.\n")
|
|
655
665
|
if skipped:
|
|
656
666
|
out.append("Not asked: " + "; ".join(f"{k['alias']} ({k['why']})" for k in skipped) + "\n")
|
|
657
667
|
emit = None if merged else on_part
|
|
@@ -674,7 +684,7 @@ def run_collection(name: str, messages: list[dict], nominated: list[str] | None
|
|
|
674
684
|
record = {"panel": panel_id, "at": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
675
685
|
"model": model, "shape": shape, "shape_by": how, "key": who, "system": system,
|
|
676
686
|
"prompt": prompt, "seats": [done[i] for i in range(len(seats))],
|
|
677
|
-
**({"skipped": skipped} if skipped else {})}
|
|
687
|
+
**({"skipped": skipped} if skipped else {}), **({"picked": picked} if picked else {})}
|
|
678
688
|
out += [section(s) for s in record["seats"]]
|
|
679
689
|
footer = ("---\nRecord a verdict per seat once checked against the code:\n"
|
|
680
690
|
"`POST /bench/verdict` on this same server (see `bench.verdict`), or"
|
|
@@ -690,7 +700,7 @@ def run_collection(name: str, messages: list[dict], nominated: list[str] | None
|
|
|
690
700
|
# A path, not a URL: the same server answered, whichever port or
|
|
691
701
|
# proxy the caller came through (the dashboard serves these routes too).
|
|
692
702
|
"verdict": 'POST /bench/verdict {"call", "useful": true|false, "note", "real": [...], "false": [...]}',
|
|
693
|
-
**({"skipped": skipped} if skipped else {})}
|
|
703
|
+
**({"skipped": skipped} if skipped else {}), **({"picked": picked} if picked else {})}
|
|
694
704
|
if merged:
|
|
695
705
|
try:
|
|
696
706
|
merger, note = merge(collection, members, shape, whole, record["seats"], task, panel_id)
|
|
@@ -716,16 +726,19 @@ def models() -> list[str]:
|
|
|
716
726
|
return [bench_collections.PREFIX + n for n in bench_collections.load()]
|
|
717
727
|
|
|
718
728
|
|
|
719
|
-
def listed(who: str) -> list[tuple[str, dict]]:
|
|
729
|
+
def listed(who: str, ad_hoc: bool = False) -> list[tuple[str, dict]]:
|
|
720
730
|
"""(model name, what it is for) for every collection key `who` may call,
|
|
721
731
|
each three times: as set, and in each other reply mode, so a client that
|
|
722
732
|
offers only listed models (an agent's model picker above all, for
|
|
723
733
|
`:route`) can still pick any. What GET /v1/models, /api/tags and
|
|
724
|
-
/v1beta/models list, each in its own shape.
|
|
734
|
+
/v1beta/models list, each in its own shape. `ad_hoc`: with the collections
|
|
735
|
+
not on offer, for looking up one a client already named."""
|
|
725
736
|
s, rows = bench_collections.settings(), []
|
|
726
737
|
for n, c in bench_collections.load().items():
|
|
727
738
|
if not guard.may_call(who, n): # a key limited to some collections sees those
|
|
728
739
|
continue
|
|
740
|
+
if c.get("ad_hoc") and not ad_hoc: # runs when named; nobody is offered it to pick
|
|
741
|
+
continue
|
|
729
742
|
d = bench_collections.described(n, c, s)
|
|
730
743
|
rows.append((bench_collections.PREFIX + n, d))
|
|
731
744
|
rows += [(f"{bench_collections.PREFIX}{n}:{other}", {**d, "reply": other})
|
|
@@ -742,6 +755,11 @@ def bench_headers(bench: dict) -> dict:
|
|
|
742
755
|
"X-Bench-Seats": json.dumps([{k: s.get(k) for k in ("call", "seat", "alias", "status")} for s in seats])}
|
|
743
756
|
|
|
744
757
|
|
|
758
|
+
def picked_headers(picked: dict | None) -> dict:
|
|
759
|
+
"""X-Bench-Picked, when a decision model chose the collection: which, by whom, how sure."""
|
|
760
|
+
return {"X-Bench-Picked": f"{picked['collection']}; {picked['by']}, {picked['confidence']:.2f}"} if picked else {}
|
|
761
|
+
|
|
762
|
+
|
|
745
763
|
def refusal(exc: Exception) -> tuple[int, str]:
|
|
746
764
|
"""The status and message for a panel that could not run."""
|
|
747
765
|
if isinstance(exc, TooLarge):
|
|
@@ -983,6 +1001,7 @@ def route_turn(name: str, plan: dict, ask: dict, who: str, emit) -> None:
|
|
|
983
1001
|
return emit("done", {"collection": model, "reply": "route", "conversation": conv["conv"], "call": call,
|
|
984
1002
|
"alias": alias, "turn": turn_no, **({"failed_over": tried} if tried else {}),
|
|
985
1003
|
**({"skipped": plan["skipped"]} if plan["skipped"] else {}),
|
|
1004
|
+
**({"picked": plan["picked"]} if plan.get("picked") else {}),
|
|
986
1005
|
"verdict": 'optional, once per conversation: POST /bench/verdict {"call", "useful", "note"}'})
|
|
987
1006
|
_log_route(conv, conv["turns"], "", bench_collections.PREFIX + name, who, "", "error", None, tried,
|
|
988
1007
|
"every member failed")
|
|
@@ -1044,7 +1063,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1044
1063
|
and (origin := guard.cors_origin(headers, self.server.server_port)):
|
|
1045
1064
|
self.send_header("Access-Control-Allow-Origin", origin)
|
|
1046
1065
|
self.send_header("Access-Control-Expose-Headers",
|
|
1047
|
-
"X-Bench-Panel, X-Bench-Seats, X-Bench-Conversation, X-Bench-Model")
|
|
1066
|
+
"X-Bench-Panel, X-Bench-Seats, X-Bench-Conversation, X-Bench-Model, X-Bench-Picked")
|
|
1048
1067
|
self.send_header("Vary", "Origin")
|
|
1049
1068
|
super().end_headers()
|
|
1050
1069
|
|
|
@@ -1098,7 +1117,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1098
1117
|
return self._json(200, formats.gemini_models(listed(self.who)))
|
|
1099
1118
|
if path.startswith("/v1beta/models/"):
|
|
1100
1119
|
name = urllib.parse.unquote(path[len("/v1beta/models/"):])
|
|
1101
|
-
row = dict(listed(self.who)).get(name)
|
|
1120
|
+
row = dict(listed(self.who, ad_hoc=True)).get(name)
|
|
1102
1121
|
return self._json(200, formats.gemini_model(name, row)) if row else \
|
|
1103
1122
|
self._error(404, f"{name!r} is not a collection here; GET /v1beta/models lists them", fmt=fmt)
|
|
1104
1123
|
if guard.mcp_route(path): # no stream from the server: every reply is a POST's
|
|
@@ -1243,7 +1262,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1243
1262
|
def _show(self, body: dict) -> None:
|
|
1244
1263
|
"""POST /api/show: Ollama's model card, for a collection the key may call."""
|
|
1245
1264
|
name = formats.ollama_model(body.get("model") or body.get("name") or "")
|
|
1246
|
-
rows = dict(listed(self.who))
|
|
1265
|
+
rows = dict(listed(self.who, ad_hoc=True))
|
|
1247
1266
|
row = rows.get(name) or rows.get(bench_collections.PREFIX + str(name))
|
|
1248
1267
|
if not row:
|
|
1249
1268
|
return self._error(404, f"model {name!r} not found; GET /api/tags lists the collections",
|
|
@@ -1255,6 +1274,14 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1255
1274
|
the key's scope and cap, then the panel, answered in that format."""
|
|
1256
1275
|
model, suffix = bench_collections.split_reply(ask["model"])
|
|
1257
1276
|
name = bench_collections.resolve(model)
|
|
1277
|
+
picked = {}
|
|
1278
|
+
if name is None and not model:
|
|
1279
|
+
# NO COLLECTION NAMED: a decision model may choose one (pick_collection); when it does
|
|
1280
|
+
# not, the refusal is the one there always was, and costs the key nothing.
|
|
1281
|
+
try:
|
|
1282
|
+
name, picked = pick_collection(ask["messages"], self.who)
|
|
1283
|
+
except Exception: # noqa: BLE001 -- refused as before, never a 500
|
|
1284
|
+
name, picked = None, {}
|
|
1258
1285
|
if name is None:
|
|
1259
1286
|
return self._error(400 if not model else 404, bench_collections.choose_message(model), fmt=fmt)
|
|
1260
1287
|
model = bench_collections.PREFIX + name
|
|
@@ -1263,30 +1290,31 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1263
1290
|
return self._error(403, f"the gateway key {self.who} may not call {model}; it may call {allowed}",
|
|
1264
1291
|
fmt=fmt)
|
|
1265
1292
|
try:
|
|
1266
|
-
guard.spend(self.who) # counted once it names a collection it may call
|
|
1293
|
+
guard.spend(self.who) # counted once it names (or is given) a collection it may call
|
|
1267
1294
|
except guard.Refused as r:
|
|
1268
1295
|
return self._error(r.status, str(r), r.headers, fmt)
|
|
1269
1296
|
reply = ask["reply"] or suffix # the body is the most specific, then the suffix, then the collection
|
|
1270
1297
|
# Chat completions have always answered with the collection's name;
|
|
1271
1298
|
# the other formats echo the name as asked, which their clients match.
|
|
1272
|
-
shown = model if fmt == "chat" else ask["model"]
|
|
1299
|
+
shown = model if fmt == "chat" or picked else ask["model"]
|
|
1273
1300
|
if (reply or bench_collections.load()[name].get("reply")) == "route":
|
|
1274
|
-
return self._route(fmt, name, ask, shown, sse)
|
|
1301
|
+
return self._route(fmt, name, ask, shown, sse, picked)
|
|
1275
1302
|
if ask["agentic"]:
|
|
1276
1303
|
return self._error(400, formats.TOOLS, fmt=fmt)
|
|
1277
1304
|
if ask["stream"]:
|
|
1278
1305
|
out = formats.INBOUND[fmt].stream(shown, prompt_tokens(ask["messages"]), sse=sse)
|
|
1279
|
-
return self._stream(fmt, name, ask, reply, out)
|
|
1306
|
+
return self._stream(fmt, name, ask, reply, out, picked)
|
|
1280
1307
|
started = time.monotonic()
|
|
1281
1308
|
try:
|
|
1282
|
-
content, bench = run_collection(name, ask["messages"], ask["with"], reply, self.who
|
|
1309
|
+
content, bench = run_collection(name, ask["messages"], ask["with"], reply, self.who,
|
|
1310
|
+
**({"picked": picked} if picked else {}))
|
|
1283
1311
|
except (ValueError, LookupError) as exc: # TooLarge is a ValueError
|
|
1284
1312
|
return self._error(*refusal(exc), fmt=fmt)
|
|
1285
1313
|
usage = (prompt_tokens(ask["messages"]), estimate_tokens(content))
|
|
1286
1314
|
self._json(200, formats.INBOUND[fmt].reply(content, shown, bench, usage, time.monotonic() - started),
|
|
1287
|
-
bench_headers(bench) if fmt == "gemini" else None)
|
|
1315
|
+
{**(bench_headers(bench) if fmt == "gemini" else {}), **picked_headers(picked)} or None)
|
|
1288
1316
|
|
|
1289
|
-
def _stream(self, fmt: str, name: str, ask: dict, reply: str, out: formats.Stream) -> None:
|
|
1317
|
+
def _stream(self, fmt: str, name: str, ask: dict, reply: str, out: formats.Stream, picked: dict | None = None) -> None:
|
|
1290
1318
|
"""The panel as it comes, in the format's stream shape: every answer
|
|
1291
1319
|
seat by seat as each finishes, or a merged reply when the merge is
|
|
1292
1320
|
done. The panel runs in its own thread and this one writes, so a
|
|
@@ -1300,7 +1328,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1300
1328
|
def work():
|
|
1301
1329
|
try:
|
|
1302
1330
|
events.put(("done", run_collection(name, ask["messages"], ask["with"], reply, self.who,
|
|
1303
|
-
on_part=lambda text: events.put(("part", text)))))
|
|
1331
|
+
on_part=lambda text: events.put(("part", text)), picked=picked)))
|
|
1304
1332
|
except Exception as exc: # noqa: BLE001 -- answered below, as a status or in the stream
|
|
1305
1333
|
events.put(("error", exc))
|
|
1306
1334
|
threading.Thread(target=work, daemon=True).start()
|
|
@@ -1317,6 +1345,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1317
1345
|
self.send_response(200)
|
|
1318
1346
|
self.send_header("Content-Type", out.content_type)
|
|
1319
1347
|
self.send_header("Cache-Control", "no-cache")
|
|
1348
|
+
for k, v in picked_headers(picked).items():
|
|
1349
|
+
self.send_header(k, v)
|
|
1320
1350
|
self.end_headers()
|
|
1321
1351
|
self.wfile.write(out.start())
|
|
1322
1352
|
started = True
|
|
@@ -1335,7 +1365,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1335
1365
|
except OSError:
|
|
1336
1366
|
return # the caller left; the panel still finishes, every seat recorded
|
|
1337
1367
|
|
|
1338
|
-
def _route(self, fmt: str, name: str, ask: dict, shown: str, sse: bool = True) -> None:
|
|
1368
|
+
def _route(self, fmt: str, name: str, ask: dict, shown: str, sse: bool = True, picked: dict | None = None) -> None:
|
|
1339
1369
|
"""Route mode: one member's answer, in the request's format. The
|
|
1340
1370
|
turn runs in its own thread (route_turn) and this one writes: a
|
|
1341
1371
|
whole reply once it is done, or a stream whose 200 waits for the
|
|
@@ -1345,6 +1375,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1345
1375
|
plan = route_plan(name, ask, self.who)
|
|
1346
1376
|
except (ValueError, LookupError) as exc: # TooLarge is a ValueError
|
|
1347
1377
|
return self._error(*refusal(exc), fmt=fmt)
|
|
1378
|
+
plan["picked"] = picked or {}
|
|
1348
1379
|
events: queue.Queue = queue.Queue()
|
|
1349
1380
|
|
|
1350
1381
|
def work():
|
|
@@ -1354,7 +1385,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1354
1385
|
events.put(("error", (500, f"route mode failed ({type(exc).__name__}: {exc})")))
|
|
1355
1386
|
threading.Thread(target=work, daemon=True).start()
|
|
1356
1387
|
tokens, began = route_tokens(ask), time.monotonic()
|
|
1357
|
-
headers = {"X-Bench-Conversation": plan["conv"]["conv"]}
|
|
1388
|
+
headers = {"X-Bench-Conversation": plan["conv"]["conv"], **picked_headers(picked)}
|
|
1358
1389
|
if not ask["stream"]:
|
|
1359
1390
|
turn = formats.Turn()
|
|
1360
1391
|
while True:
|
package/mcp_server.py
CHANGED
|
@@ -52,7 +52,8 @@ TOOLS = [
|
|
|
52
52
|
"title": "List collections",
|
|
53
53
|
"description": "The collections this Benchwarmers server offers: name, what each is for, tags, members,"
|
|
54
54
|
" explorer seats, reply mode, and what the members handle together (smallest context"
|
|
55
|
-
" window, shared capabilities, tightest rate limits). Pick by task before calling ask."
|
|
55
|
+
" window, shared capabilities, tightest rate limits). Pick by task before calling ask."
|
|
56
|
+
" A collection the user names may not be listed here: ask it anyway.",
|
|
56
57
|
"inputSchema": {"type": "object", "properties": {}, "additionalProperties": False},
|
|
57
58
|
"annotations": {"readOnlyHint": True, "openWorldHint": False}},
|
|
58
59
|
{"name": "ask",
|
package/metrics/alerts.py
CHANGED
|
@@ -76,6 +76,31 @@ def clear(key: str) -> bool:
|
|
|
76
76
|
return _update(lambda data: data.pop(key, None) is not None)
|
|
77
77
|
|
|
78
78
|
|
|
79
|
+
def answered(alias: str, served: str) -> list[str]:
|
|
80
|
+
"""A model answered: clear what an answer disproves. Every alert about it,
|
|
81
|
+
by its alias or a `model:<kind>:<served>` key (retiring, unrecognised
|
|
82
|
+
failure, never served, gone, entitlement, paywalled), except a price
|
|
83
|
+
(`model:paid:`), which an answer says nothing about. A held connection
|
|
84
|
+
loses it from its list, and goes once none is left. The cleared keys."""
|
|
85
|
+
def resolve(data: dict) -> list[str]:
|
|
86
|
+
gone = []
|
|
87
|
+
for key, a in list(data.items()):
|
|
88
|
+
if a.get("kind") == "connection":
|
|
89
|
+
held = a.get("aliases") or []
|
|
90
|
+
if alias not in held:
|
|
91
|
+
continue
|
|
92
|
+
a["aliases"] = [x for x in held if x != alias]
|
|
93
|
+
if a["aliases"]:
|
|
94
|
+
continue
|
|
95
|
+
elif key.startswith("model:paid:") or not (
|
|
96
|
+
a.get("alias") == alias or (key.startswith("model:") and key.split(":", 2)[2:] == [served])):
|
|
97
|
+
continue
|
|
98
|
+
del data[key]
|
|
99
|
+
gone.append(key)
|
|
100
|
+
return gone
|
|
101
|
+
return _update(resolve)
|
|
102
|
+
|
|
103
|
+
|
|
79
104
|
def sweep(prefix: str, still_wrong: set[str]) -> list[str]:
|
|
80
105
|
"""Retire every alert under `prefix` that the caller did not re-raise.
|
|
81
106
|
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
config/collections.json {"core": {"members": [...], "shape": "",
|
|
4
4
|
"description": "...", "tags": ["general"],
|
|
5
5
|
"reply": "all", "merger": ""}}
|
|
6
|
-
config/gateway.json {"max_calls": 5, "max_explorers": 2
|
|
6
|
+
config/gateway.json {"max_calls": 5, "max_explorers": 2,
|
|
7
|
+
"pick_collection": true, "pick_confidence": 0.6}
|
|
7
8
|
|
|
8
9
|
A request for `bench/core` runs every member, plus explorer seats that
|
|
9
10
|
`route.py` fills by staleness so the roster keeps getting evidence even when
|
|
@@ -11,6 +12,12 @@ the same collection is called every day:
|
|
|
11
12
|
|
|
12
13
|
explorers = min(max_explorers, max_calls - members)
|
|
13
14
|
|
|
15
|
+
A collection may hold its own `max_calls` and `max_explorers`; what it leaves
|
|
16
|
+
out follows gateway.json, so changing that moves every collection without its
|
|
17
|
+
own. A member out of quota keeps its place and an explorer sits in for it
|
|
18
|
+
until the quota is back (gateway.py), unless max_explorers is 0: then only the
|
|
19
|
+
members ever run.
|
|
20
|
+
|
|
14
21
|
`shape` (review, design, …) decides which models may fill those explorer
|
|
15
22
|
seats; empty means a decision model labels each prompt, or `general` without
|
|
16
23
|
one. `reply` is what the caller reads: `all` (every answer, the default) or
|
|
@@ -19,7 +26,9 @@ verdict record, gateway.py) or `route` (no panel: one member, picked by its
|
|
|
19
26
|
record, gets the request as sent, tools and all, for agent clients;
|
|
20
27
|
GATEWAY.md "Route mode"). A request may override it with a `:merged`,
|
|
21
28
|
`:all` or `:route` suffix on the model name, or `"bench": {"reply": ...}`
|
|
22
|
-
in the body.
|
|
29
|
+
in the body. `ad_hoc` (false unless set) keeps a collection out of everything
|
|
30
|
+
that offers collections to choose from: it runs when a request names it, as an
|
|
31
|
+
ad hoc model does (suite/run.py), for the expensive ones a person decides on.
|
|
23
32
|
Named `bench_collections` because `collections` would shadow the standard
|
|
24
33
|
library for everything importing from metrics/.
|
|
25
34
|
"""
|
|
@@ -35,7 +44,8 @@ from paths import DATA # noqa: E402 -- the install's data folder (metrics/path
|
|
|
35
44
|
FILE = DATA / "config" / "collections.json"
|
|
36
45
|
SETTINGS_FILE = DATA / "config" / "gateway.json"
|
|
37
46
|
DEFAULTS = {"max_calls": 5, "max_explorers": 2}
|
|
38
|
-
|
|
47
|
+
BOUNDS = {"max_calls": (1, 10), "max_explorers": (0, 2)} # route.py seats at most two explorers
|
|
48
|
+
NAME =re.compile(r"^[a-z0-9][a-z0-9-]{1,31}$")
|
|
39
49
|
PREFIX = "bench/"
|
|
40
50
|
REPLIES = ("all", "merged", "route")
|
|
41
51
|
|
|
@@ -67,6 +77,30 @@ def settings() -> dict:
|
|
|
67
77
|
return {**DEFAULTS, **{k: v for k, v in _read(SETTINGS_FILE).items() if k in DEFAULTS}}
|
|
68
78
|
|
|
69
79
|
|
|
80
|
+
def pick_settings() -> dict:
|
|
81
|
+
"""gateway.json's `pick_collection` (on unless false: a request that names
|
|
82
|
+
no collection gets one a decision model chooses, gateway.pick_collection)
|
|
83
|
+
and `pick_confidence` (above 0, at most 1; None means decide.CONFIDENT).
|
|
84
|
+
Kept beside the limits, which save_gateway_settings leaves alone."""
|
|
85
|
+
raw = _read(SETTINGS_FILE)
|
|
86
|
+
on, at = raw.get("pick_collection"), raw.get("pick_confidence")
|
|
87
|
+
return {"pick_collection": on is not False,
|
|
88
|
+
"pick_confidence": at if isinstance(at, (int, float)) and not isinstance(at, bool) and 0 < at <= 1 else None}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def limit_ok(key: str, value) -> bool:
|
|
92
|
+
return isinstance(value, int) and not isinstance(value, bool) and BOUNDS[key][0] <= value <= BOUNDS[key][1]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def own_limits(c: dict) -> dict:
|
|
96
|
+
"""The limits a collection sets for itself; a hand-edited one out of bounds counts as unset."""
|
|
97
|
+
return {k: c[k] for k in DEFAULTS if limit_ok(k, c.get(k))}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def limits(c: dict, s: dict | None = None) -> dict:
|
|
101
|
+
return {**(s or settings()), **own_limits(c)}
|
|
102
|
+
|
|
103
|
+
|
|
70
104
|
def explorers_for(members: int, s: dict | None = None) -> int:
|
|
71
105
|
s = s or settings()
|
|
72
106
|
return max(0, min(s["max_explorers"], s["max_calls"] - members))
|
|
@@ -81,8 +115,8 @@ def described(name: str, c: dict, s: dict | None = None) -> dict:
|
|
|
81
115
|
members = c.get("members", [])
|
|
82
116
|
out = {"description": c.get("description") or c.get("note", ""),
|
|
83
117
|
"tags": c.get("tags", []), "members": members, "shape": c.get("shape", ""),
|
|
84
|
-
"explorers": explorers_for(len(members), s),
|
|
85
|
-
"reply": c.get("reply") or "all", "merger": c.get("merger", "")}
|
|
118
|
+
"explorers": explorers_for(len(members), limits(c, s)), **own_limits(c),
|
|
119
|
+
"reply": c.get("reply") or "all", "merger": c.get("merger", ""), "ad_hoc": bool(c.get("ad_hoc"))}
|
|
86
120
|
# What the members can handle together (metrics/catalog.py): the smallest
|
|
87
121
|
# context window is what the gateway refuses above.
|
|
88
122
|
try:
|
|
@@ -95,8 +129,10 @@ def described(name: str, c: dict, s: dict | None = None) -> dict:
|
|
|
95
129
|
|
|
96
130
|
def resolve(model: str | None) -> str | None:
|
|
97
131
|
"""The collection a request's `model` names: `bench/core` or `core`; None
|
|
98
|
-
when it names nothing here. There is no default: a
|
|
99
|
-
|
|
132
|
+
when it names nothing here. There is no default: a request with no model
|
|
133
|
+
gets one a decision model chooses when it is sure (gateway.pick_collection,
|
|
134
|
+
pick_settings), and is otherwise told what each collection is for and
|
|
135
|
+
asked to choose, as is one naming a collection that does not exist."""
|
|
100
136
|
name = (model or "").strip().removeprefix(PREFIX) # "bench/" alone names nothing
|
|
101
137
|
return name if name and name in load() else None
|
|
102
138
|
|
|
@@ -122,11 +158,11 @@ def reply_errors(reply: str, merger: str, known) -> list[str]:
|
|
|
122
158
|
|
|
123
159
|
def choose_message(model: str | None) -> str:
|
|
124
160
|
"""The refusal for a request that names no collection, or the wrong one:
|
|
125
|
-
every collection with its description and tags, so the caller can choose."""
|
|
161
|
+
every collection on offer with its description and tags, so the caller can choose."""
|
|
126
162
|
asked = f"{model!r} is not a collection here." if model else "No collection was named."
|
|
127
163
|
rows = [f"- {PREFIX}{n}: {c.get('description') or 'no description'}"
|
|
128
164
|
+ (f" [{', '.join(c.get('tags', []))}]" if c.get("tags") else "")
|
|
129
|
-
for n, c in sorted(load().items())]
|
|
165
|
+
for n, c in sorted(load().items()) if not c.get("ad_hoc")]
|
|
130
166
|
return (f"{asked} Set \"model\" to the collection that fits the task"
|
|
131
167
|
" (GET /v1/models lists them with description and tags):\n"
|
|
132
168
|
+ ("\n".join(rows) or "- none yet: create one in the dashboard's Collections dialog"))
|
|
@@ -136,10 +172,23 @@ if __name__ == "__main__":
|
|
|
136
172
|
# DEFAULTS, not settings(): the install's gateway.json must not decide the check.
|
|
137
173
|
assert [explorers_for(n, DEFAULTS) for n in range(1, 7)] == [2, 2, 2, 1, 0, 0]
|
|
138
174
|
assert explorers_for(3, {"max_calls": 4, "max_explorers": 2}) == 1
|
|
175
|
+
assert limits({"max_calls": 4, "max_explorers": 0}, DEFAULTS) == {"max_calls": 4, "max_explorers": 0}
|
|
176
|
+
assert limits({"max_calls": 99, "max_explorers": True}, DEFAULTS) == DEFAULTS and limits({}, DEFAULTS) == DEFAULTS
|
|
177
|
+
assert explorers_for(4, limits({"max_calls": 4}, DEFAULTS)) == 0
|
|
139
178
|
assert resolve("bench/") is None and resolve("bench/no-such") is None and resolve("") is None
|
|
140
179
|
assert split_reply("bench/core:merged") == ("bench/core", "merged") and split_reply("core:all") == ("core", "all")
|
|
141
180
|
assert split_reply("bench/core:route") == ("bench/core", "route")
|
|
142
181
|
assert split_reply("bench/core") == ("bench/core", "") and split_reply("bench/core:x") == ("bench/core:x", "")
|
|
182
|
+
import tempfile
|
|
183
|
+
real = SETTINGS_FILE
|
|
184
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
185
|
+
SETTINGS_FILE = pathlib.Path(tmp) / "gateway.json"
|
|
186
|
+
assert pick_settings() == {"pick_collection": True, "pick_confidence": None} # on by default
|
|
187
|
+
SETTINGS_FILE.write_text('{"pick_collection": false, "pick_confidence": 0.8}', encoding="utf-8")
|
|
188
|
+
assert pick_settings() == {"pick_collection": False, "pick_confidence": 0.8}
|
|
189
|
+
SETTINGS_FILE.write_text('{"pick_confidence": 2}', encoding="utf-8") # out of range: unset
|
|
190
|
+
assert pick_settings() == {"pick_collection": True, "pick_confidence": None}
|
|
191
|
+
SETTINGS_FILE = real
|
|
143
192
|
assert not reply_errors("merged", "", []) and not reply_errors("all", "m", ["m"])
|
|
144
193
|
assert len(reply_errors("", "m", [])) == 2
|
|
145
194
|
print("ok")
|