@wendellresearch-ai/benchwarmers 0.5.6 → 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/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: a request that names no collection, or one
15
- that does not exist, is refused with every collection's description and tags,
16
- so the caller can choose — `GET /v1/models` carries the same.
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); otherwise, or on any doubt,
34
- the shape is `general`. A request body may add `"with": ["<alias>"]` to seat a
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 is asked to choose between. Each option carries a
159
- # description because bare names mislead it: Mercury Decide called "list every
160
- # place serve.py writes a file" `code` at 0.93 when given names alone.
161
- SHAPES = {
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
- DATA_URLS = re.compile(r"data:[\w/.+-]*;base64,[A-Za-z0-9+/=_\\-]*", re.I)
181
- URLS = re.compile(r"https?://[^\s\"'<>\\]+", re.I)
182
- BASE64 = re.compile(r"[A-Za-z0-9+/]{100,}={0,2}")
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). Falls back to general on any doubt."""
293
- try:
294
- held = set(json.loads((DATA / "config" / "model-roles.json").read_text(encoding="utf-8"))
295
- .get("ad_hoc", []))
296
- except (OSError, ValueError, AttributeError):
297
- return "general", "config/model-roles.json unreadable"
298
- picker = next((a for a, d in decide.load().items()
299
- if "choice" in d["question_types"] and a not in held), None)
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
@@ -390,9 +376,10 @@ def ask_seat(alias: str, system: str, prompt: str, messages: list[dict] | None =
390
376
  else:
391
377
  answer, error = _ask_llm(cmd, prompt)
392
378
  # A quota or credit notice delivered as the reply's text, with a success
393
- # 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).
394
381
  from run import provider_notice
395
- if not error and (notice := provider_notice(answer)):
382
+ if not error and (notice := provider_notice(answer, ask=True)):
396
383
  error, answer = f"provider notice in a success reply ({notice}): {answer[:200]}", ""
397
384
  if messages is not None:
398
385
  # Before the error is cut, logged or learned from. ponytail: the answer
@@ -407,7 +394,8 @@ def ask_seat(alias: str, system: str, prompt: str, messages: list[dict] | None =
407
394
  except Exception: # noqa: BLE001
408
395
  pass
409
396
  status = "error" if error else ("empty" if not answer else "ok")
410
- return {"status": status, "answer": answer, "error": error[-500:],
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),
411
399
  "seconds": round(time.monotonic() - started, 1)}
412
400
 
413
401
 
@@ -555,10 +543,13 @@ def section(seat: dict) -> str:
555
543
 
556
544
 
557
545
  def run_collection(name: str, messages: list[dict], nominated: list[str] | None = None,
558
- 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]:
559
547
  """The panel's reply as markdown, and the same seats as data for the
560
548
  response's `bench` field. `reply`: the request's mode, "" for the
561
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).
562
553
 
563
554
  `on_part`, for a streaming caller: called with each piece of an
564
555
  every-answer reply as it is ready, the heading once the panel is seated,
@@ -669,6 +660,8 @@ def run_collection(name: str, messages: list[dict], nominated: list[str] | None
669
660
  task = " ".join(prompt.split())[:90] + f" ({model}, {shape})"
670
661
  out = [f"Bench {model} {panel_id} · {len(sitting)} members + {len(seats) - len(sitting)}"
671
662
  f" explorers · explorer shape {shape} ({how})\n"]
663
+ if picked:
664
+ out.append(f"Collection {model} chosen for this request: {picked['why']}.\n")
672
665
  if skipped:
673
666
  out.append("Not asked: " + "; ".join(f"{k['alias']} ({k['why']})" for k in skipped) + "\n")
674
667
  emit = None if merged else on_part
@@ -691,7 +684,7 @@ def run_collection(name: str, messages: list[dict], nominated: list[str] | None
691
684
  record = {"panel": panel_id, "at": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
692
685
  "model": model, "shape": shape, "shape_by": how, "key": who, "system": system,
693
686
  "prompt": prompt, "seats": [done[i] for i in range(len(seats))],
694
- **({"skipped": skipped} if skipped else {})}
687
+ **({"skipped": skipped} if skipped else {}), **({"picked": picked} if picked else {})}
695
688
  out += [section(s) for s in record["seats"]]
696
689
  footer = ("---\nRecord a verdict per seat once checked against the code:\n"
697
690
  "`POST /bench/verdict` on this same server (see `bench.verdict`), or"
@@ -707,7 +700,7 @@ def run_collection(name: str, messages: list[dict], nominated: list[str] | None
707
700
  # A path, not a URL: the same server answered, whichever port or
708
701
  # proxy the caller came through (the dashboard serves these routes too).
709
702
  "verdict": 'POST /bench/verdict {"call", "useful": true|false, "note", "real": [...], "false": [...]}',
710
- **({"skipped": skipped} if skipped else {})}
703
+ **({"skipped": skipped} if skipped else {}), **({"picked": picked} if picked else {})}
711
704
  if merged:
712
705
  try:
713
706
  merger, note = merge(collection, members, shape, whole, record["seats"], task, panel_id)
@@ -762,6 +755,11 @@ def bench_headers(bench: dict) -> dict:
762
755
  "X-Bench-Seats": json.dumps([{k: s.get(k) for k in ("call", "seat", "alias", "status")} for s in seats])}
763
756
 
764
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
+
765
763
  def refusal(exc: Exception) -> tuple[int, str]:
766
764
  """The status and message for a panel that could not run."""
767
765
  if isinstance(exc, TooLarge):
@@ -1003,6 +1001,7 @@ def route_turn(name: str, plan: dict, ask: dict, who: str, emit) -> None:
1003
1001
  return emit("done", {"collection": model, "reply": "route", "conversation": conv["conv"], "call": call,
1004
1002
  "alias": alias, "turn": turn_no, **({"failed_over": tried} if tried else {}),
1005
1003
  **({"skipped": plan["skipped"]} if plan["skipped"] else {}),
1004
+ **({"picked": plan["picked"]} if plan.get("picked") else {}),
1006
1005
  "verdict": 'optional, once per conversation: POST /bench/verdict {"call", "useful", "note"}'})
1007
1006
  _log_route(conv, conv["turns"], "", bench_collections.PREFIX + name, who, "", "error", None, tried,
1008
1007
  "every member failed")
@@ -1064,7 +1063,7 @@ class Handler(BaseHTTPRequestHandler):
1064
1063
  and (origin := guard.cors_origin(headers, self.server.server_port)):
1065
1064
  self.send_header("Access-Control-Allow-Origin", origin)
1066
1065
  self.send_header("Access-Control-Expose-Headers",
1067
- "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")
1068
1067
  self.send_header("Vary", "Origin")
1069
1068
  super().end_headers()
1070
1069
 
@@ -1275,6 +1274,14 @@ class Handler(BaseHTTPRequestHandler):
1275
1274
  the key's scope and cap, then the panel, answered in that format."""
1276
1275
  model, suffix = bench_collections.split_reply(ask["model"])
1277
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, {}
1278
1285
  if name is None:
1279
1286
  return self._error(400 if not model else 404, bench_collections.choose_message(model), fmt=fmt)
1280
1287
  model = bench_collections.PREFIX + name
@@ -1283,30 +1290,31 @@ class Handler(BaseHTTPRequestHandler):
1283
1290
  return self._error(403, f"the gateway key {self.who} may not call {model}; it may call {allowed}",
1284
1291
  fmt=fmt)
1285
1292
  try:
1286
- 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
1287
1294
  except guard.Refused as r:
1288
1295
  return self._error(r.status, str(r), r.headers, fmt)
1289
1296
  reply = ask["reply"] or suffix # the body is the most specific, then the suffix, then the collection
1290
1297
  # Chat completions have always answered with the collection's name;
1291
1298
  # the other formats echo the name as asked, which their clients match.
1292
- shown = model if fmt == "chat" else ask["model"]
1299
+ shown = model if fmt == "chat" or picked else ask["model"]
1293
1300
  if (reply or bench_collections.load()[name].get("reply")) == "route":
1294
- return self._route(fmt, name, ask, shown, sse)
1301
+ return self._route(fmt, name, ask, shown, sse, picked)
1295
1302
  if ask["agentic"]:
1296
1303
  return self._error(400, formats.TOOLS, fmt=fmt)
1297
1304
  if ask["stream"]:
1298
1305
  out = formats.INBOUND[fmt].stream(shown, prompt_tokens(ask["messages"]), sse=sse)
1299
- return self._stream(fmt, name, ask, reply, out)
1306
+ return self._stream(fmt, name, ask, reply, out, picked)
1300
1307
  started = time.monotonic()
1301
1308
  try:
1302
- 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 {}))
1303
1311
  except (ValueError, LookupError) as exc: # TooLarge is a ValueError
1304
1312
  return self._error(*refusal(exc), fmt=fmt)
1305
1313
  usage = (prompt_tokens(ask["messages"]), estimate_tokens(content))
1306
1314
  self._json(200, formats.INBOUND[fmt].reply(content, shown, bench, usage, time.monotonic() - started),
1307
- bench_headers(bench) if fmt == "gemini" else None)
1315
+ {**(bench_headers(bench) if fmt == "gemini" else {}), **picked_headers(picked)} or None)
1308
1316
 
1309
- 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:
1310
1318
  """The panel as it comes, in the format's stream shape: every answer
1311
1319
  seat by seat as each finishes, or a merged reply when the merge is
1312
1320
  done. The panel runs in its own thread and this one writes, so a
@@ -1320,7 +1328,7 @@ class Handler(BaseHTTPRequestHandler):
1320
1328
  def work():
1321
1329
  try:
1322
1330
  events.put(("done", run_collection(name, ask["messages"], ask["with"], reply, self.who,
1323
- on_part=lambda text: events.put(("part", text)))))
1331
+ on_part=lambda text: events.put(("part", text)), picked=picked)))
1324
1332
  except Exception as exc: # noqa: BLE001 -- answered below, as a status or in the stream
1325
1333
  events.put(("error", exc))
1326
1334
  threading.Thread(target=work, daemon=True).start()
@@ -1337,6 +1345,8 @@ class Handler(BaseHTTPRequestHandler):
1337
1345
  self.send_response(200)
1338
1346
  self.send_header("Content-Type", out.content_type)
1339
1347
  self.send_header("Cache-Control", "no-cache")
1348
+ for k, v in picked_headers(picked).items():
1349
+ self.send_header(k, v)
1340
1350
  self.end_headers()
1341
1351
  self.wfile.write(out.start())
1342
1352
  started = True
@@ -1355,7 +1365,7 @@ class Handler(BaseHTTPRequestHandler):
1355
1365
  except OSError:
1356
1366
  return # the caller left; the panel still finishes, every seat recorded
1357
1367
 
1358
- 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:
1359
1369
  """Route mode: one member's answer, in the request's format. The
1360
1370
  turn runs in its own thread (route_turn) and this one writes: a
1361
1371
  whole reply once it is done, or a stream whose 200 waits for the
@@ -1365,6 +1375,7 @@ class Handler(BaseHTTPRequestHandler):
1365
1375
  plan = route_plan(name, ask, self.who)
1366
1376
  except (ValueError, LookupError) as exc: # TooLarge is a ValueError
1367
1377
  return self._error(*refusal(exc), fmt=fmt)
1378
+ plan["picked"] = picked or {}
1368
1379
  events: queue.Queue = queue.Queue()
1369
1380
 
1370
1381
  def work():
@@ -1374,7 +1385,7 @@ class Handler(BaseHTTPRequestHandler):
1374
1385
  events.put(("error", (500, f"route mode failed ({type(exc).__name__}: {exc})")))
1375
1386
  threading.Thread(target=work, daemon=True).start()
1376
1387
  tokens, began = route_tokens(ask), time.monotonic()
1377
- headers = {"X-Bench-Conversation": plan["conv"]["conv"]}
1388
+ headers = {"X-Bench-Conversation": plan["conv"]["conv"], **picked_headers(picked)}
1378
1389
  if not ask["stream"]:
1379
1390
  turn = formats.Turn()
1380
1391
  while True:
@@ -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
@@ -76,6 +77,17 @@ def settings() -> dict:
76
77
  return {**DEFAULTS, **{k: v for k, v in _read(SETTINGS_FILE).items() if k in DEFAULTS}}
77
78
 
78
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
+
79
91
  def limit_ok(key: str, value) -> bool:
80
92
  return isinstance(value, int) and not isinstance(value, bool) and BOUNDS[key][0] <= value <= BOUNDS[key][1]
81
93
 
@@ -117,8 +129,10 @@ def described(name: str, c: dict, s: dict | None = None) -> dict:
117
129
 
118
130
  def resolve(model: str | None) -> str | None:
119
131
  """The collection a request's `model` names: `bench/core` or `core`; None
120
- when it names nothing here. There is no default: a caller that did not
121
- choose is told what each collection is for and asked to choose."""
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."""
122
136
  name = (model or "").strip().removeprefix(PREFIX) # "bench/" alone names nothing
123
137
  return name if name and name in load() else None
124
138
 
@@ -165,6 +179,16 @@ if __name__ == "__main__":
165
179
  assert split_reply("bench/core:merged") == ("bench/core", "merged") and split_reply("core:all") == ("core", "all")
166
180
  assert split_reply("bench/core:route") == ("bench/core", "route")
167
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
168
192
  assert not reply_errors("merged", "", []) and not reply_errors("all", "m", ["m"])
169
193
  assert len(reply_errors("", "m", [])) == 2
170
194
  print("ok")
@@ -31,6 +31,12 @@ WHERE THE FACTS COME FROM
31
31
  the same weights, but a provider can cap context lower.
32
32
  - Open WebUI's /api/models, where a connection passes metadata through
33
33
  (Inception, Reflection do; most do not).
34
+ - A local model server's own API, for what its /v1/models leaves out: LM
35
+ Studio's /api/v1/models (vision, tool use, reasoning; the context window a
36
+ loaded model runs with, else the most it can take) and Ollama's /api/show
37
+ (its capabilities, and `<arch>.context_length` from model_info, the trained
38
+ window: Ollama may run it with less). Like a connection's own listing, these
39
+ beat a same-id match from anywhere else.
34
40
  - Everything else, including rate limits, by hand: freellmapi.co/models is a
35
41
  good place to look them up. The bench never fetches it; its data licence is
36
42
  not stated.
@@ -64,8 +70,9 @@ LIMIT_LABELS = {"rpm": "requests per minute", "rpd": "requests per day", "tpm":
64
70
 
65
71
 
66
72
  def _read(path: pathlib.Path) -> dict:
73
+ import locked
67
74
  try:
68
- data = json.loads(path.read_text(encoding="utf-8"))
75
+ data = json.loads(locked.read_text(path)) # inside a render, from its snapshot
69
76
  except (OSError, ValueError):
70
77
  return {}
71
78
  return data if isinstance(data, dict) else {}
@@ -151,6 +158,39 @@ def _fetch(url: str, headers: dict | None = None) -> dict:
151
158
  return json.load(r)
152
159
 
153
160
 
161
+ def _native(name: str, served) -> tuple[str, dict]:
162
+ """What a local model server says of its own models that its /v1/models
163
+ leaves out: LM Studio's /api/v1/models, else Ollama's /api/show, one call
164
+ for each of `served`. (source, {served id: facts}); ("", {}) from any other."""
165
+ import connect
166
+ import endpoints
167
+ root, secret = endpoints.base(name).removesuffix("/v1"), endpoints.key(name)
168
+ auth = endpoints.auth_headers("chat", secret) if secret else {}
169
+ status, data = connect.look(root + "/api/v1/models", 5, headers=auth)
170
+ out: dict[str, dict] = {}
171
+ if status == 200 and isinstance(data, dict) and isinstance(data.get("models"), list):
172
+ for m in data["models"]:
173
+ caps, runs = m.get("capabilities") or {}, m.get("loaded_instances") or []
174
+ # Loaded: the window it runs with now; else the most it can take.
175
+ ctx = min(filter(None, ((i.get("config") or {}).get("context_length") for i in runs)),
176
+ default=m.get("max_context_length"))
177
+ facts = {"context_window": ctx, "vision": caps.get("vision"),
178
+ "tools": caps.get("trained_for_tool_use"), "reasoning": "reasoning" in caps}
179
+ # A request names the key, a variant (key@q4_k_m) or a loaded instance's id.
180
+ for i in (m.get("key"), *(m.get("variants") or ()), *(r.get("id") for r in runs)):
181
+ out[i] = facts
182
+ return "lmstudio /api/v1/models", out
183
+ for s in served:
184
+ status, d = connect.look(root + "/api/show", 5, {"model": s}, auth)
185
+ if status == 200 and isinstance(d, dict) and isinstance(d.get("capabilities"), list):
186
+ caps, mi = d["capabilities"], d.get("model_info") or {}
187
+ # ponytail: the trained window; Ollama runs a model with its own num_ctx
188
+ # (OLLAMA_CONTEXT_LENGTH, a Modelfile's) and cuts a longer prompt silently.
189
+ out[s] = {"context_window": mi.get(f"{mi.get('general.architecture')}.context_length"),
190
+ "vision": "vision" in caps, "tools": "tools" in caps, "reasoning": "thinking" in caps}
191
+ return ("ollama /api/show" if out else ""), out
192
+
193
+
154
194
  def _watch_price(alias: str, served: str, conn: str, old: dict | None, new: dict | None) -> None:
155
195
  """A free model that now has a price: raise it, before calls start failing.
156
196
  models.dev can lag, so this is an early warning, not proof; the bench's own
@@ -188,11 +228,17 @@ def refresh() -> dict:
188
228
  # is registered on: the direct openrouter endpoint lists qwen/qwen3.8-27b
189
229
  # too, and pooling the listings filed OpenRouter's 1M window as groq's.
190
230
  listings: dict[str, dict] = {}
231
+ native: dict[str, tuple[str, dict]] = {} # a local server's own API, what /v1/models leaves out
191
232
  for name in endpoints.load():
192
233
  try:
193
234
  listings[name] = endpoints.models(name)
194
235
  except Exception: # noqa: BLE001
195
236
  listings[name] = {}
237
+ try:
238
+ if endpoints.kind(name) == "local":
239
+ native[name] = _native(name, listings[name])
240
+ except Exception: # noqa: BLE001 -- an answer of another shape: nothing from it
241
+ pass
196
242
  home = {r["alias"]: r["endpoint"] for r in endpoints.registry()}
197
243
  models = {}
198
244
  for alias, served in registered.items():
@@ -269,9 +315,11 @@ def refresh() -> dict:
269
315
  structured_output=bool(feats & {"json_mode", "structured_outputs"}))
270
316
  if entry.get("input_modalities") is not None:
271
317
  own["vision"] = "image" in entry["input_modalities"]
272
- for k, v in own.items():
273
- if v is not None:
274
- rec[k], src[k] = v, f"{ep or 'endpoint'} listing"
318
+ said, facts = native.get(ep, ("", {}))
319
+ for mine, by in ((own, f"{ep or 'endpoint'} listing"), (facts.get(served, {}), said)):
320
+ for k, v in mine.items():
321
+ if v is not None:
322
+ rec[k], src[k] = v, by
275
323
  rec["sources"] = src
276
324
  models[alias] = rec
277
325
  _watch_price(alias, served, conn, before.get(alias, {}).get("cost"), rec.get("cost"))
package/metrics/client.py CHANGED
@@ -57,7 +57,7 @@ down sets.
57
57
  WHAT EVERY CALL LEAVES BEHIND (2b)
58
58
  ----------------------------------
59
59
  - a ledger line (metrics/ledger.py): tokens in and out, seconds, status,
60
- attempts, caller;
60
+ attempts, caller, and a failed call's error (`clip`: its start and end);
61
61
  - quota records from the reply's rate-limit headers and any 429 body
62
62
  (metrics/quota.py);
63
63
  - a silent-swap note when the reply names another model than the one
@@ -119,6 +119,7 @@ import json
119
119
  import os
120
120
  import pathlib
121
121
  import random
122
+ import re
122
123
  import sys
123
124
  import threading
124
125
  import time
@@ -852,6 +853,11 @@ def _record(alias: str, res: dict, ctx: dict, t0: float, purpose: str) -> None:
852
853
  row["swap"] = True
853
854
  if res.get("budget_retry"):
854
855
  row["budget_retry"] = res["budget_retry"]
856
+ if status == "error":
857
+ # What failed, so the uptime report can tell our quota from the
858
+ # provider being down for calls no other log keeps (the suite's).
859
+ # Redacted first: an upstream may echo the request, images and all.
860
+ row["error"] = clip(redact(res["error"] or ""))
855
861
  ledger.append(row)
856
862
  except Exception as e: # noqa: BLE001
857
863
  # The slot stays taken until it expires (the call's deadline and a
@@ -906,6 +912,45 @@ def output(res: dict, timeout: float) -> str:
906
912
  return res["error"] or (res["text"] + "\n" if res["text"] else "")
907
913
 
908
914
 
915
+ DATA_URLS = re.compile(r"data:[\w/.+-]*;base64,[A-Za-z0-9+/=_\\-]*", re.I)
916
+ URLS = re.compile(r"https?://[^\s\"'<>\\]+", re.I)
917
+ BASE64 = re.compile(r"[A-Za-z0-9+/]{100,}={0,2}")
918
+
919
+
920
+ def placeholder(url: str) -> str:
921
+ """How an image is written down (the call log, offloads, the dashboard,
922
+ error text, the merger's copy of the request), never its bytes: a data
923
+ URL by its size, an http URL without its query (a signed URL's signature
924
+ is a credential) or a user:password."""
925
+ if url[:5].lower() == "data:":
926
+ return f"[image: data URL, {(len(url) * 3 // 4 + 999) // 1000:,} KB]"
927
+ u = urllib.parse.urlsplit(url)
928
+ return f"[image: {u.scheme}://{u.netloc.rpartition('@')[2]}{u.path[:200]}{'?...' if u.query else ''}]"
929
+
930
+
931
+ def redact(text: str) -> str:
932
+ """`text` with each inline image as its placeholder, every http(s) URL
933
+ without its query or user:password, and any other long base64 run as its
934
+ size: an upstream may echo the request in an error, in whatever form, and
935
+ errors are logged, shown and learned from."""
936
+ def bare(m):
937
+ try:
938
+ u = urllib.parse.urlsplit(m.group())
939
+ except ValueError: # an unparseable netloc, such as "[::1"
940
+ return "[url]"
941
+ return f"{u.scheme}://{u.netloc.rpartition('@')[2]}{u.path}{'?...' if u.query else ''}"
942
+ text = URLS.sub(bare, DATA_URLS.sub(lambda m: placeholder(m.group()), text))
943
+ return BASE64.sub(lambda m: f"[base64, {(len(m.group()) * 3 // 4 + 999) // 1000:,} KB]", text)
944
+
945
+
946
+ def clip(text: str, head: int = 200, tail: int = 300) -> str:
947
+ """An error kept short for a log, its start AND its end: the start says the
948
+ HTTP status (`Error: Error code: 429 - ...`), the end what a long body
949
+ (headers, a traceback, an HTML page) finally said. The uptime report
950
+ (suite/run.uptime_class) classifies from it."""
951
+ return text if len(text) <= head + tail else f"{text[:head]} … {text[-tail:]}"
952
+
953
+
909
954
  LOG_LOCKED = "sqlite3.OperationalError: database is locked"
910
955
 
911
956
 
@@ -930,6 +975,11 @@ def _selftest() -> None:
930
975
  assert not llm_log_failed_only("", tb) # locked before the call
931
976
  assert not llm_log_failed_only("half an ans", "openai.APIError: stream cut\n")
932
977
  assert not llm_log_failed_only("pong", tb + "Error: something after\n")
978
+ # A long error keeps its status (the start) and its last words (the end), and no image.
979
+ long = "Error: Error code: 429 - " + "x" * 900 + " retry later"
980
+ assert clip(long).startswith("Error: Error code: 429") and clip(long).endswith("retry later")
981
+ assert len(clip(long)) == 503 and clip("short") == "short"
982
+ assert "AAAA" not in clip(redact("Error code: 400 - data:image/png;base64," + "A" * 900))
933
983
  ok = (b'event: x\ndata: {"type":"response.output_text.delta","delta":"re"}\n\n'
934
984
  b'data: {"type":"response.completed","response":{"model":"m","usage":{"total_tokens":3},'
935
985
  b'"output":[{"type":"reasoning"},{"type":"message","content":[{"type":"output_text","text":"ready"}]}]}}\n')