@wendellresearch-ai/benchwarmers 0.5.4 → 0.5.6
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 +4 -1
- package/GATEWAY.md +26 -1
- package/PRIVACY.md +6 -1
- package/README.md +16 -4
- package/SECURITY.md +2 -1
- package/USAGE.md +16 -5
- package/VERSION +1 -1
- package/cli.py +12 -1
- package/gateway.py +29 -9
- package/mcp_server.py +2 -1
- package/metrics/alerts.py +25 -0
- package/metrics/bench_collections.py +31 -6
- package/metrics/catalog.py +2 -5
- package/metrics/discover.py +191 -11
- package/metrics/owui.py +26 -0
- package/metrics/route.py +10 -3
- package/metrics/sources.py +15 -2
- package/package.json +1 -1
- package/scripts/test_formats.py +61 -1
- package/scripts/test_serve_edit.py +38 -0
- package/serve.py +116 -55
- package/suite/run.py +5 -3
- package/ui/bench.css +28 -32
- package/ui/bench.js +39 -13
package/serve.py
CHANGED
|
@@ -554,7 +554,7 @@ def _return_to_collections(alias: str, was: dict, stamp: str, who: str) -> list[
|
|
|
554
554
|
Returns the names it went back into."""
|
|
555
555
|
if not was or bench_collections.unreadable(bench_collections.FILE):
|
|
556
556
|
return []
|
|
557
|
-
data,
|
|
557
|
+
data, s = bench_collections.load(), bench_collections.settings()
|
|
558
558
|
back = []
|
|
559
559
|
for name, fields in was.items():
|
|
560
560
|
c = data.get(name)
|
|
@@ -562,7 +562,7 @@ def _return_to_collections(alias: str, was: dict, stamp: str, who: str) -> list[
|
|
|
562
562
|
continue
|
|
563
563
|
old = json.loads(json.dumps(c))
|
|
564
564
|
members = c.get("members") or []
|
|
565
|
-
if "members" in fields and alias not in members and len(members) <
|
|
565
|
+
if "members" in fields and alias not in members and len(members) < bench_collections.limits(c, s)["max_calls"]:
|
|
566
566
|
c["members"] = members + [alias]
|
|
567
567
|
if "merger" in fields and not c.get("merger"):
|
|
568
568
|
c["merger"] = alias
|
|
@@ -698,15 +698,21 @@ def dismiss_alert(key: str) -> dict:
|
|
|
698
698
|
|
|
699
699
|
|
|
700
700
|
def retry_model(alias: str, who: str = "ui") -> dict:
|
|
701
|
-
"""One fresh call to a model on
|
|
701
|
+
"""One fresh call to a model on Action needed. An answer is written to
|
|
702
702
|
perf/history.csv as evidence (scripts/auto_retire.py reads it, so the
|
|
703
|
-
clock resets) and
|
|
704
|
-
check. A failure writes nothing: the daily probe keeps
|
|
703
|
+
clock resets) and its alerts are cleared now rather than at the next 5am
|
|
704
|
+
check (alerts.answered). A failure writes nothing: the daily probe keeps
|
|
705
|
+
the record.
|
|
706
|
+
|
|
707
|
+
One request, never the client's retries: a 429's stated wait (UnoRouter's
|
|
708
|
+
60 s) then a second request spent a minute and another call of a 1-a-minute
|
|
709
|
+
free tier, and Retry all did that for each model in turn. A person can
|
|
710
|
+
click again."""
|
|
705
711
|
names = registered()
|
|
706
712
|
if alias not in names.values():
|
|
707
713
|
return {"ok": False, "errors": [f"{alias!r} is not registered"]}
|
|
708
714
|
from run import ask_once, failure_class, llm_binary
|
|
709
|
-
body, seconds = ask_once(llm_binary(), alias, "Reply with exactly one word: ready", 90)
|
|
715
|
+
body, seconds = ask_once(llm_binary(), alias, "Reply with exactly one word: ready", 90, retries=0)
|
|
710
716
|
cls = failure_class(body, min_length=1)
|
|
711
717
|
if cls is not None:
|
|
712
718
|
try: # a stated quota or wait still teaches a cooldown
|
|
@@ -720,12 +726,10 @@ def retry_model(alias: str, who: str = "ui") -> dict:
|
|
|
720
726
|
f"{seconds:.2f}", f"{seconds:.2f}", f"{seconds:.2f}", f"{seconds:.2f}",
|
|
721
727
|
0.0, 1, 0, 0, 0, "", "retry from the dashboard", ""])
|
|
722
728
|
import alerts as _alerts
|
|
723
|
-
|
|
724
|
-
#
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
for key in cleared:
|
|
728
|
-
_alerts.clear(key)
|
|
729
|
+
# Every alert the answer disproves, not only a retiring one: 2026-11-02,
|
|
730
|
+
# big-pickle answered and stayed on Action needed as "healthy", its
|
|
731
|
+
# unrecognised-failure alert still open.
|
|
732
|
+
cleared = _alerts.answered(alias, {a: s for s, a in names.items()}.get(alias, ""))
|
|
729
733
|
_log_change(datetime.datetime.now().astimezone().isoformat(timespec="seconds"), who, alias,
|
|
730
734
|
"retried", None, f"answered in {seconds:.1f}s")
|
|
731
735
|
return {"ok": True, "errors": [], "sync": "",
|
|
@@ -1506,19 +1510,25 @@ LONG_CONTEXT = 128_000 # the long-context tag: members known to hold at
|
|
|
1506
1510
|
|
|
1507
1511
|
|
|
1508
1512
|
def save_collection(name: str, members, shape: str, description: str, tags=None,
|
|
1509
|
-
reply: str = "all", merger: str = "", who: str = "ui"
|
|
1513
|
+
reply: str = "all", merger: str = "", ad_hoc: bool = False, who: str = "ui",
|
|
1514
|
+
max_calls=None, max_explorers=None) -> dict:
|
|
1510
1515
|
"""Create or replace a collection. Every member must be a chat model on the
|
|
1511
1516
|
bench (a decision model cannot answer a prompt), no more than max_calls.
|
|
1512
1517
|
The description and tags are what an orchestrator reads on /v1/models to
|
|
1513
|
-
choose a collection, so they say what it is for.
|
|
1518
|
+
choose a collection, so they say what it is for. `ad_hoc` takes it off
|
|
1519
|
+
/v1/models: it runs only when a request names it. `max_calls` and
|
|
1520
|
+
`max_explorers`: its own limits; None follows the gateway settings."""
|
|
1514
1521
|
# The name is what follows "bench/"; one typed with the prefix is taken as meant.
|
|
1515
1522
|
name, shape = (name or "").strip().removeprefix(bench_collections.PREFIX), (shape or "").strip()
|
|
1516
1523
|
description = " ".join((description or "").split())
|
|
1517
1524
|
if isinstance(tags, str):
|
|
1518
1525
|
tags = tags.split(",")
|
|
1519
1526
|
tags = list(dict.fromkeys(t.strip().lower() for t in (tags or []) if isinstance(t, str) and t.strip()))
|
|
1520
|
-
|
|
1521
|
-
|
|
1527
|
+
own = {k: v for k, v in (("max_calls", max_calls), ("max_explorers", max_explorers)) if v is not None}
|
|
1528
|
+
s = bench_collections.limits(own)
|
|
1529
|
+
errors = [f"{k.replace('_', ' ')} must be a whole number from {bench_collections.BOUNDS[k][0]} to"
|
|
1530
|
+
f" {bench_collections.BOUNDS[k][1]}, or empty for the gateway setting"
|
|
1531
|
+
for k, v in own.items() if not bench_collections.limit_ok(k, v)]
|
|
1522
1532
|
if not bench_collections.NAME.match(name):
|
|
1523
1533
|
errors.append("name: 2-32 lower-case letters, digits or '-', saying what it is for,"
|
|
1524
1534
|
" e.g. reviewers; callers ask for bench/<name>")
|
|
@@ -1529,7 +1539,7 @@ def save_collection(name: str, members, shape: str, description: str, tags=None,
|
|
|
1529
1539
|
if not members:
|
|
1530
1540
|
errors.append("a collection needs at least one member")
|
|
1531
1541
|
if len(members) > s["max_calls"]:
|
|
1532
|
-
errors.append(f"at most {s['max_calls']} members (max calls per request
|
|
1542
|
+
errors.append(f"at most {s['max_calls']} members (max calls per request)")
|
|
1533
1543
|
unknown = [m for m in members if m not in known]
|
|
1534
1544
|
if unknown:
|
|
1535
1545
|
errors.append(f"not chat models on the bench: {', '.join(unknown)}")
|
|
@@ -1553,7 +1563,7 @@ def save_collection(name: str, members, shape: str, description: str, tags=None,
|
|
|
1553
1563
|
data = bench_collections.load()
|
|
1554
1564
|
old = data.get(name)
|
|
1555
1565
|
data[name] = {"members": members, "shape": shape, "description": description, "tags": tags,
|
|
1556
|
-
"reply": reply, "merger": merger}
|
|
1566
|
+
"reply": reply, "merger": merger, **({"ad_hoc": True} if ad_hoc else {}), **own}
|
|
1557
1567
|
_write_atomic(bench_collections.FILE, _dump(data))
|
|
1558
1568
|
_log_change(datetime.datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
1559
1569
|
who, "", f"collection {name}", old, data[name])
|
|
@@ -1580,7 +1590,8 @@ def save_gateway_settings(max_calls, max_explorers, who: str = "ui") -> dict:
|
|
|
1580
1590
|
if not isinstance(max_explorers, int) or isinstance(max_explorers, bool) or not 0 <= max_explorers <= 2:
|
|
1581
1591
|
errors.append("max explorers must be 0, 1 or 2 (route.py seats at most two)")
|
|
1582
1592
|
too_big = [n for n, c in bench_collections.load().items()
|
|
1583
|
-
if isinstance(max_calls, int) and len(c.get("members", [])) > max_calls
|
|
1593
|
+
if isinstance(max_calls, int) and len(c.get("members", [])) > max_calls
|
|
1594
|
+
and "max_calls" not in bench_collections.own_limits(c)]
|
|
1584
1595
|
if too_big:
|
|
1585
1596
|
errors.append(f"these collections have more members than {max_calls}: {', '.join(too_big)}")
|
|
1586
1597
|
if bench_collections.unreadable(bench_collections.SETTINGS_FILE):
|
|
@@ -2129,21 +2140,31 @@ def actions_page(groups: dict, auto: list) -> str:
|
|
|
2129
2140
|
shown = re.sub(r"^https?://", "", url).split("?")[0]
|
|
2130
2141
|
return f" <a href='{esc(url)}' target='_blank' rel='noopener'>{esc(shown)} ↗</a>"
|
|
2131
2142
|
|
|
2143
|
+
def why(text: str, url: str, title: str) -> str:
|
|
2144
|
+
return (f"<td class='why' title='{esc(title)}'><div class='said-line'><span class='said'>{esc(text)}</span>"
|
|
2145
|
+
f"{link(url)}</div></td>")
|
|
2146
|
+
|
|
2132
2147
|
def model_row(r: dict) -> str:
|
|
2133
2148
|
if not r["alias"]: # a notice: what it says, and Got it if it goes once seen
|
|
2134
2149
|
got = (f"<button class='linkish' data-act='dismiss' data-key='{esc(r['key'])}'>Got it</button>"
|
|
2135
2150
|
if r.get("dismiss") else "")
|
|
2136
|
-
return (f"<
|
|
2137
|
-
f"<
|
|
2138
|
-
f"<span class='acts'>{got}</span></div>")
|
|
2151
|
+
return (f"<tr data-row><td class='what'>{esc(r['said'])}</td><td>{chip(r['label'], r['css'])}</td>"
|
|
2152
|
+
f"<td class='why'>{esc(r['title'])}</td><td class='acts'>{got}</td></tr>")
|
|
2139
2153
|
alias = esc(r["alias"])
|
|
2140
|
-
return (f"<
|
|
2141
|
-
f"
|
|
2142
|
-
|
|
2143
|
-
f"<
|
|
2154
|
+
return (f"<tr data-row><td><a href='#' class='model' data-alias='{alias}'><span class='alias'>{breakable(alias)}"
|
|
2155
|
+
f"</span></a></td><td data-sort='{r['days']:03d}{esc(r['label'])}'>{chip(r['label'], r['css'])}</td>"
|
|
2156
|
+
+ why(r["said"], r["url"], r["title"])
|
|
2157
|
+
+ f"<td class='acts'><button class='linkish' data-act='retry' data-alias='{alias}'"
|
|
2144
2158
|
" title='One call now; an answer clears the alert'>Retry</button>"
|
|
2145
2159
|
f"<button class='linkish danger' data-act='retire' data-alias='{alias}'"
|
|
2146
|
-
f" data-why='{esc(r['why'] or r['label'])}'>Retire</button></
|
|
2160
|
+
f" data-why='{esc(r['why'] or r['label'])}'>Retire</button></td></tr>")
|
|
2161
|
+
|
|
2162
|
+
# One column plan for every table, as on Models, so they line up down the page.
|
|
2163
|
+
cols = "<colgroup><col class='c-model'><col class='c-state'><col><col class='c-acts'></colgroup>"
|
|
2164
|
+
|
|
2165
|
+
def table(first: str, second: str, rows: str) -> str:
|
|
2166
|
+
return (f"<table class='a-table'>{cols}<thead><tr>{th(first)}{th(second)}{th('Reason')}<th></th></tr></thead>"
|
|
2167
|
+
f"<tbody>{rows}</tbody></table>")
|
|
2147
2168
|
|
|
2148
2169
|
def section(conn: str, g: dict) -> str:
|
|
2149
2170
|
aliases = [r["alias"] for r in g["rows"] if r["alias"]]
|
|
@@ -2156,29 +2177,30 @@ def actions_page(groups: dict, auto: list) -> str:
|
|
|
2156
2177
|
f"<div class='a-held' title='{esc(h['title'])}'><span class='chip'>held</span><span>Every model"
|
|
2157
2178
|
f" here is failing, likely the connection (key, credit, prefix), so nothing was retired."
|
|
2158
2179
|
f" Latest: {esc(h['said'])}{link(h['url'])}</span></div>" for h in g["held"])
|
|
2159
|
-
n = len(g["rows"])
|
|
2160
|
-
return (f"<div class='
|
|
2161
|
-
f"
|
|
2162
|
-
f"<span class='
|
|
2163
|
-
|
|
2180
|
+
n, notices = len(g["rows"]), conn == "Notices"
|
|
2181
|
+
return (f"<div class='panel'><div class='a-head' data-row><h3 class='chart-head'>{esc(conn)} · {n}"
|
|
2182
|
+
f" {'notice' if notices else 'model'}{'s' * (n != 1)}</h3><span class='why'></span>"
|
|
2183
|
+
f"<span class='acts'>{both}</span></div>{held}"
|
|
2184
|
+
+ table("Notice" if notices else "Model", "Kind" if notices else "State",
|
|
2185
|
+
"".join(model_row(r) for r in g["rows"])) + "</div>")
|
|
2164
2186
|
|
|
2165
2187
|
tags = load_json(DATA / "config" / "provider-tags.json") # a retired model's connection, by served name
|
|
2166
2188
|
gone = "".join(
|
|
2167
|
-
f"<
|
|
2168
|
-
f"<span class='alias'>{breakable(esc(alias))}</span></a><
|
|
2169
|
-
f"
|
|
2170
|
-
|
|
2171
|
-
f"
|
|
2172
|
-
f"<span class='acts'><button class='linkish' data-act='restore' data-alias='{esc(alias)}'>Restore</button></span></div>"
|
|
2189
|
+
f"<tr data-row><td><a href='#' class='model' data-alias='{esc(alias)}'>"
|
|
2190
|
+
f"<span class='alias'>{breakable(esc(alias))}</span></a></td><td class='when'>{esc(r['at'][:10])}</td>"
|
|
2191
|
+
+ why(f"{tags.get(r.get('served', ''), '').strip('?') or r.get('endpoint') or endpoints.default()}"
|
|
2192
|
+
f" · {said(r.get('why', '').removeprefix('auto: '))[0]}", "", r.get("why", ""))
|
|
2193
|
+
+ f"<td class='acts'><button class='linkish' data-act='restore' data-alias='{esc(alias)}'>Restore</button></td></tr>"
|
|
2173
2194
|
for alias, r in auto)
|
|
2174
2195
|
# Folded: a week of automatic retirements is a record to check, not a list of things to do.
|
|
2175
|
-
folded = (f"<details class='a-retired'><summary>Retired automatically
|
|
2176
|
-
f" <span class='muted'
|
|
2177
|
-
if auto else "")
|
|
2178
|
-
body = "".join(section(conn, g) for conn, g in groups.items())
|
|
2196
|
+
folded = (f"<div class='panel'><details class='a-retired'><summary><h3 class='chart-head'>Retired automatically"
|
|
2197
|
+
f" this week · {len(auto)}</h3> <span class='muted'>each can be restored</span></summary>"
|
|
2198
|
+
f"{table('Model', 'Retired', gone)}</details></div>" if auto else "")
|
|
2199
|
+
body = "".join(section(conn, g) for conn, g in groups.items())
|
|
2179
2200
|
return ("<div class='panel'><h2>Action needed</h2><p class='sub'>States that will not fix themselves, by the"
|
|
2180
2201
|
" connection they come from. A reason's full error is on hover, and a name opens the model."
|
|
2181
|
-
|
|
2202
|
+
" Retry spends one call; an answer clears the alert.</p>"
|
|
2203
|
+
+ ("" if body else "<p class='a-none'>Nothing open.</p>") + f"</div>{body}{folded}")
|
|
2182
2204
|
|
|
2183
2205
|
|
|
2184
2206
|
CAP_LABELS = {"tools": "tools", "vision": "vision", "structured_output": "json", "reasoning": "reasons"}
|
|
@@ -3116,8 +3138,8 @@ def _discover_rows() -> list[dict]:
|
|
|
3116
3138
|
(Settings' free-only-in-their-own-app switches)."""
|
|
3117
3139
|
import discover
|
|
3118
3140
|
import sources
|
|
3119
|
-
paths = [sources.CACHE / f"{n}.json" for n in sources.FEEDS] + [
|
|
3120
|
-
|
|
3141
|
+
paths = [sources.CACHE / f"{n}.json" for n in [*sources.FEEDS, discover.LISTS]] + [
|
|
3142
|
+
endpoints.YAML, endpoints.FILE, endpoints.CONNECTIONS]
|
|
3121
3143
|
stamp = tuple(p.stat().st_mtime if p.exists() else 0 for p in paths)
|
|
3122
3144
|
if _DISCOVER.get("stamp") != stamp:
|
|
3123
3145
|
_DISCOVER.update(stamp=stamp, rows=discover.build())
|
|
@@ -3142,6 +3164,17 @@ def discover_model(provider: str, model: str) -> dict:
|
|
|
3142
3164
|
return {"ok": True, "errors": errors, "model": row, "routes": routes}
|
|
3143
3165
|
|
|
3144
3166
|
|
|
3167
|
+
def discover_refresh(who: str = "ui") -> dict:
|
|
3168
|
+
"""Discover's Refresh: every list fetched now (discover.refresh), for a
|
|
3169
|
+
model too new to have waited for the daily fetch. A list that would not
|
|
3170
|
+
come keeps its last copy and is said; the rest are still used."""
|
|
3171
|
+
import discover
|
|
3172
|
+
said = discover.refresh(force=True)
|
|
3173
|
+
_log_change(datetime.datetime.now().astimezone().isoformat(timespec="seconds"), who, "", "discover-refresh",
|
|
3174
|
+
None, "; ".join(said) or "every list fetched")
|
|
3175
|
+
return {"ok": True, "errors": said}
|
|
3176
|
+
|
|
3177
|
+
|
|
3145
3178
|
def discover_card() -> str:
|
|
3146
3179
|
"""Models worth adding, from FreeLLMAPI and models.dev (metrics/discover.py),
|
|
3147
3180
|
deduped. Read from the daily cache, so the page never waits on the network;
|
|
@@ -3158,7 +3191,7 @@ def discover_card() -> str:
|
|
|
3158
3191
|
caps = [c for c, on in (("tools", r["tools"]), ("vision", r["vision"]),
|
|
3159
3192
|
("reasons", r["reasoning"])) if on]
|
|
3160
3193
|
cost = r.get("cost") or {}
|
|
3161
|
-
price = ("free" if r["free"] else
|
|
3194
|
+
price = ("free" if r["free"] else "unknown" if r["free"] is None else
|
|
3162
3195
|
f"{cost.get('input', '?')} / {cost.get('output', '?')}" if cost else "paid")
|
|
3163
3196
|
lim = r.get("limits") or {}
|
|
3164
3197
|
lim_text = " · ".join([f"{v:,} {k}" for k, v in lim.items()]
|
|
@@ -3192,9 +3225,17 @@ def discover_card() -> str:
|
|
|
3192
3225
|
f"<td>{add}</td></tr>")
|
|
3193
3226
|
body.append("</tbody></table>")
|
|
3194
3227
|
free = sum(1 for r in rows if r["free"])
|
|
3228
|
+
import discover
|
|
3229
|
+
import sources
|
|
3230
|
+
ages = [a for a in map(sources.age_s, [*sources.FEEDS, discover.LISTS]) if a is not None]
|
|
3195
3231
|
toolbar = ("<div class='toolbar'><input type='search' data-filter placeholder='Filter by model,"
|
|
3196
3232
|
" provider or reach' aria-label='Filter rows'>"
|
|
3197
|
-
"<label><input type='checkbox' data-paid> Show paid (connected providers)</label
|
|
3233
|
+
"<label><input type='checkbox' data-paid> Show paid and unpriced (connected providers)</label>"
|
|
3234
|
+
"<button class='btn small' type='button' id='d-refresh' title='Fetch FreeLLMAPI, models.dev and"
|
|
3235
|
+
" every Open WebUI connection's own model list now, instead of waiting for the daily fetch'>"
|
|
3236
|
+
"Refresh lists</button><span class='muted' id='d-refreshed'>"
|
|
3237
|
+
+ ("never fetched" if not ages else f"oldest list {max(ages) / 60:.0f} minutes old" if max(ages) < 5400
|
|
3238
|
+
else f"oldest list {max(ages) / 3600:.0f} hours old") + "</span></div>")
|
|
3198
3239
|
key = """<dl>
|
|
3199
3240
|
<dt>Rank</dt><dd>FreeLLMAPI's smartest-first order (1 is the smartest it lists); within
|
|
3200
3241
|
each reach the list follows it. Hover for its fastest-first rank and size. A dash where
|
|
@@ -3207,7 +3248,8 @@ def discover_card() -> str:
|
|
|
3207
3248
|
for its base URL); <em>other</em> a native API or an unvetted reseller.</dd>
|
|
3208
3249
|
<dt>Price</dt><dd>Free if FreeLLMAPI lists it (a catalog of free tiers) or models.dev
|
|
3209
3250
|
prices it at 0: models.dev shows the PAID price even where a free tier exists. Otherwise
|
|
3210
|
-
input / output per million tokens
|
|
3251
|
+
input / output per million tokens. <em>Unknown</em> where only the provider's own list has
|
|
3252
|
+
the model and it says nothing of price; those are behind <em>Show paid and unpriced</em>.</dd>
|
|
3211
3253
|
<dt>Limits</dt><dd>The free tier's, from FreeLLMAPI: requests and tokens per minute (rpm,
|
|
3212
3254
|
tpm) and per day (rpd, tpd), and roughly how many tokens a month that allows.</dd>
|
|
3213
3255
|
<dt>New</dt><dd>First seen in either list in the last 7 days.</dd>
|
|
@@ -3225,11 +3267,13 @@ def discover_card() -> str:
|
|
|
3225
3267
|
<dt>Sources</dt><dd>FreeLLMAPI's free catalog first: where it lists a model, its name,
|
|
3226
3268
|
provider, context, tools, limits, ranks and quirks lead, and the popup links its
|
|
3227
3269
|
freellmapi.co page. models.dev fills the rest and alone has the paid models, prices,
|
|
3228
|
-
base URLs and modalities
|
|
3229
|
-
|
|
3270
|
+
base URLs and modalities; the popup links its page for the model. A model one provider
|
|
3271
|
+
offers in both is shown once. Then each Open WebUI connection's own model list, for
|
|
3272
|
+
what the provider serves that neither catalog has yet. All are fetched daily;
|
|
3273
|
+
<em>Refresh lists</em> fetches them now.</dd>
|
|
3230
3274
|
</dl>"""
|
|
3231
|
-
return (f"<div class='panel'><h2>Discover</h2><p class='sub'>{free} free models from FreeLLMAPI"
|
|
3232
|
-
f"
|
|
3275
|
+
return (f"<div class='panel'><h2>Discover</h2><p class='sub'>{free} free models from FreeLLMAPI,"
|
|
3276
|
+
f" models.dev and your providers' own lists, deduped; {hidden:,} paid models on providers you have no route"
|
|
3233
3277
|
f" to are left out. Click a model for everything known about it and the routes it can be"
|
|
3234
3278
|
f" added by.</p>{toolbar}"
|
|
3235
3279
|
f"<div class='scroll'>{''.join(body)}</div>"
|
|
@@ -3271,7 +3315,7 @@ def collections_tab() -> str:
|
|
|
3271
3315
|
" as <code>code</code> or <code>prose</code> the roles it does not admit; <code>free</code> hides"
|
|
3272
3316
|
" the paid ones. Click a suggestion or type your own: comma-separated lower-case words, up to 8.</p>"
|
|
3273
3317
|
# Not data-filter: that binds to the whole panel. The same match (any
|
|
3274
|
-
# text, case-insensitive), on the member list only;
|
|
3318
|
+
# text, case-insensitive), on the member list only; the seats show who is ticked.
|
|
3275
3319
|
"<label class='field' for='c-member-filter'>Members</label>"
|
|
3276
3320
|
# The panel as it will sit, drawn as the Collections list draws it
|
|
3277
3321
|
# (bench.js recount): a seat per ticked member, explorer seats dashed.
|
|
@@ -3282,6 +3326,15 @@ def collections_tab() -> str:
|
|
|
3282
3326
|
"<option value=''>Every provider</option></select></div>"
|
|
3283
3327
|
"<div id='c-members' class='members' role='group' aria-label='Members'></div>"
|
|
3284
3328
|
"<p class='rules' id='c-count'></p>"
|
|
3329
|
+
"<div class='settings'>"
|
|
3330
|
+
"<div><label class='field' for='c-max-calls'>Max calls per request</label>"
|
|
3331
|
+
"<input id='c-max-calls' type='number' min='1' max='10'></div>"
|
|
3332
|
+
"<div><label class='field' for='c-max-explorers'>Max explorers</label>"
|
|
3333
|
+
"<input id='c-max-explorers' type='number' min='0' max='2'></div>"
|
|
3334
|
+
"</div>"
|
|
3335
|
+
"<p class='rules'>Empty follows the Gateway settings, shown greyed. A member out of quota stays"
|
|
3336
|
+
" on the collection and an explorer sits in for it until the quota is back. Max explorers 0"
|
|
3337
|
+
" means only the members ever run, stand-ins included.</p>"
|
|
3285
3338
|
"<label class='field' for='c-shape'>Explorer shape</label><select id='c-shape'></select>"
|
|
3286
3339
|
"<p class='rules'>Which models may fill the explorer seats. Automatic asks a decision"
|
|
3287
3340
|
" model to label each prompt when one is onboarded, else general.</p>"
|
|
@@ -3300,6 +3353,10 @@ def collections_tab() -> str:
|
|
|
3300
3353
|
"<label class='field' for='c-desc'>Description</label>"
|
|
3301
3354
|
"<textarea id='c-desc' rows='2' maxlength='300'"
|
|
3302
3355
|
" placeholder='e.g. Adversarial code review: finding bugs and broken guarantees in a diff'></textarea>"
|
|
3356
|
+
"<label class='check'><input type='checkbox' id='c-ad-hoc'> Ad hoc: only when called by name</label>"
|
|
3357
|
+
"<p class='rules'>Left off <code>GET /v1/models</code> and the list agents choose from, so an"
|
|
3358
|
+
" agent picking for itself never spends it. For the expensive ones you decide to run: say"
|
|
3359
|
+
" <code>bench/<name></code> and it runs. Not a lock: anyone who knows the name can call it.</p>"
|
|
3303
3360
|
"<div class='errors' id='c-errors' role='alert'></div><div class='done' id='c-done'></div>"
|
|
3304
3361
|
"<div class='actions'><button class='btn danger' id='c-delete' hidden>Delete collection</button>"
|
|
3305
3362
|
"<span class='spacer'></span><button class='btn' id='c-close'>Close</button>"
|
|
@@ -3313,7 +3370,8 @@ def collections_tab() -> str:
|
|
|
3313
3370
|
"<input id='g-max-explorers' type='number' min='0' max='2'></div>"
|
|
3314
3371
|
"</div>"
|
|
3315
3372
|
"<p class='rules'>Explorers = min(max explorers, max calls − members): with 5 and 2, a"
|
|
3316
|
-
" 2- or 3-model collection gets 2, a 4-model one 1, a 5-model one none.
|
|
3373
|
+
" 2- or 3-model collection gets 2, a 4-model one 1, a 5-model one none. These apply to"
|
|
3374
|
+
" every collection that does not set its own in its dialog. A request that"
|
|
3317
3375
|
" names no collection is refused with each one's description and tags, so the caller"
|
|
3318
3376
|
" can choose.</p>"
|
|
3319
3377
|
"<div class='errors' id='g-errors' role='alert'></div><div class='done' id='g-done'></div>"
|
|
@@ -4423,6 +4481,7 @@ class Handler(gateway.Handler):
|
|
|
4423
4481
|
"/api/remove": lambda p: remove_model(str(p.get("alias", "")), str(p.get("reason", ""))),
|
|
4424
4482
|
"/api/restore": lambda p: restore_model(str(p.get("alias", ""))),
|
|
4425
4483
|
"/api/retry": lambda p: retry_model(str(p.get("alias") or "")),
|
|
4484
|
+
"/api/discover/refresh": lambda p: discover_refresh(),
|
|
4426
4485
|
"/api/discover/add": lambda p: discover_add(str(p.get("connection") or ""),
|
|
4427
4486
|
str(p.get("model") or ""),
|
|
4428
4487
|
endpoint=str(p.get("endpoint") or ""),
|
|
@@ -4455,7 +4514,9 @@ class Handler(gateway.Handler):
|
|
|
4455
4514
|
str(p.get("shape") or ""),
|
|
4456
4515
|
str(p.get("description") or ""),
|
|
4457
4516
|
p.get("tags"), str(p.get("reply") or ""),
|
|
4458
|
-
str(p.get("merger") or "")),
|
|
4517
|
+
str(p.get("merger") or ""), p.get("ad_hoc") is True,
|
|
4518
|
+
max_calls=p.get("max_calls"),
|
|
4519
|
+
max_explorers=p.get("max_explorers")),
|
|
4459
4520
|
"/api/collection/remove": lambda p: delete_collection(str(p.get("name") or "")),
|
|
4460
4521
|
"/api/model-facts": lambda p: save_model_facts(str(p.get("alias") or ""), p.get("facts")),
|
|
4461
4522
|
"/api/model-options": lambda p: save_model_options(str(p.get("alias") or ""), p.get("max_tokens")),
|
package/suite/run.py
CHANGED
|
@@ -920,9 +920,11 @@ def _child_env() -> dict:
|
|
|
920
920
|
|
|
921
921
|
|
|
922
922
|
def ask_once(binary: str, model: str, prompt: str,
|
|
923
|
-
timeout: int, images: list[str] | None = None) -> tuple[str, float]:
|
|
923
|
+
timeout: int, images: list[str] | None = None, retries: int | None = None) -> tuple[str, float]:
|
|
924
924
|
# BENCH_CLIENT=builtin: the same call without the `llm` subprocess, giving
|
|
925
|
-
# the same text for the same failure (metrics/client.py).
|
|
925
|
+
# the same text for the same failure (metrics/client.py). `retries`: the
|
|
926
|
+
# built-in client's tries after a 429, 5xx or connection error (its two
|
|
927
|
+
# by default); the llm backend keeps its SDK's own.
|
|
926
928
|
if str(ROOT / "metrics") not in sys.path:
|
|
927
929
|
sys.path.insert(0, str(ROOT / "metrics"))
|
|
928
930
|
import client
|
|
@@ -935,7 +937,7 @@ def ask_once(binary: str, model: str, prompt: str,
|
|
|
935
937
|
purpose="vision task")
|
|
936
938
|
return client.output(res, timeout), res["seconds"]
|
|
937
939
|
if client.backend(model) == "builtin":
|
|
938
|
-
res = client.chat(model, prompt, timeout=timeout)
|
|
940
|
+
res = client.chat(model, prompt, timeout=timeout, retries=retries)
|
|
939
941
|
return client.output(res, timeout), res["seconds"]
|
|
940
942
|
started = time.monotonic()
|
|
941
943
|
try:
|
package/ui/bench.css
CHANGED
|
@@ -115,16 +115,11 @@ main { max-width: var(--page, 104rem); margin: 0 auto; padding: 0 var(--gutter)
|
|
|
115
115
|
.problems .head { display: flex; align-items: baseline; gap: .5rem; }
|
|
116
116
|
.problems .head h2 { margin-bottom: .25rem; }
|
|
117
117
|
|
|
118
|
-
/* Action needed: its own page,
|
|
119
|
-
|
|
120
|
-
the columns line up down the group. */
|
|
118
|
+
/* Action needed: its own page, a panel per connection as on Models, each a
|
|
119
|
+
table (name, state, the provider's words, actions). */
|
|
121
120
|
.a-line { display: flex; flex-wrap: wrap; align-items: baseline; gap: .25rem .6rem; margin: 1.4rem 1.6rem 0;
|
|
122
121
|
padding: .45rem .75rem; background: var(--err-ground); border-radius: var(--radius); }
|
|
123
|
-
.a-
|
|
124
|
-
.a-group:first-of-type { border-top: 0; padding-top: .5rem; }
|
|
125
|
-
.a-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: .25rem .6rem; margin-bottom: .4rem; }
|
|
126
|
-
.a-head .conn { font-size: 1.0625rem; font-weight: 700; }
|
|
127
|
-
.a-head .count { color: var(--muted); }
|
|
122
|
+
.a-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: .25rem .6rem; }
|
|
128
123
|
.a-head .why { font-size: .875rem; }
|
|
129
124
|
.a-head .why:empty { display: none; }
|
|
130
125
|
.a-head .acts { margin-left: auto; display: flex; gap: .5rem; }
|
|
@@ -132,26 +127,26 @@ main { max-width: var(--page, 104rem); margin: 0 auto; padding: 0 var(--gutter)
|
|
|
132
127
|
background: var(--warn-ground); border-radius: var(--radius); font-size: .9375rem; overflow-wrap: anywhere; }
|
|
133
128
|
.a-held .chip { flex: none; background: var(--ink); color: var(--paper); }
|
|
134
129
|
.a-held a { color: var(--ink); font-weight: 500; }
|
|
135
|
-
/* The same fixed columns in every
|
|
136
|
-
|
|
137
|
-
|
|
130
|
+
/* The same fixed columns in every table, so state, words and actions line
|
|
131
|
+
up down the whole page whatever each group's longest name; a long name
|
|
132
|
+
wraps in its column. */
|
|
138
133
|
#tab-needs > .panel { container-type: inline-size; }
|
|
139
|
-
.a-
|
|
140
|
-
|
|
141
|
-
.a-
|
|
142
|
-
.a-
|
|
143
|
-
.a-
|
|
144
|
-
.a-
|
|
145
|
-
.a-row .why, .a-row .when { color: var(--muted); font-size: .875rem; overflow-wrap: anywhere; }
|
|
134
|
+
table.a-table { table-layout: fixed; }
|
|
135
|
+
table.a-table col.c-model { width: 30%; }
|
|
136
|
+
table.a-table col.c-state { width: 9.5rem; }
|
|
137
|
+
table.a-table col.c-acts { width: 7.5rem; }
|
|
138
|
+
.a-table .alias, .a-table .what { font-weight: 500; overflow-wrap: anywhere; } /* a name breaks at its dots first (<wbr>) */
|
|
139
|
+
.a-table .why, .a-table .when { color: var(--muted); font-size: .875rem; overflow-wrap: anywhere; }
|
|
146
140
|
/* One line a model: the provider's words cut to the column, whole on hover; the link gives way last. */
|
|
147
|
-
.a-
|
|
148
|
-
.a-
|
|
149
|
-
.a-
|
|
150
|
-
.a-
|
|
141
|
+
.a-table .said-line { display: flex; gap: .5rem; min-width: 0; }
|
|
142
|
+
.a-table .said { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
143
|
+
.a-table .said-line > a { flex: none; max-width: 50%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
144
|
+
.a-table td.acts { white-space: nowrap; font-size: .875rem; }
|
|
145
|
+
.a-table td.acts .linkish + .linkish { margin-left: .9rem; }
|
|
151
146
|
.linkish.danger { color: var(--err); }
|
|
152
|
-
.a-retired
|
|
153
|
-
.a-retired > summary {
|
|
154
|
-
.a-retired > summary .muted { color: var(--muted);
|
|
147
|
+
.a-retired > summary { cursor: pointer; }
|
|
148
|
+
.a-retired > summary h3 { display: inline; }
|
|
149
|
+
.a-retired > summary .muted { color: var(--muted); }
|
|
155
150
|
.a-none { color: var(--muted); }
|
|
156
151
|
|
|
157
152
|
/* pages: a grouped sidebar (Observe, Gateway, Control); the group names and
|
|
@@ -550,13 +545,14 @@ dialog .pick { display: flex; gap: 1.5rem; }
|
|
|
550
545
|
state on one line, the words and actions under them. No gap between name
|
|
551
546
|
and state, so the line above a model runs unbroken. */
|
|
552
547
|
@container (max-width: 46rem) {
|
|
553
|
-
.a-
|
|
554
|
-
.a-
|
|
555
|
-
.a-
|
|
556
|
-
.a-
|
|
557
|
-
.a-
|
|
558
|
-
.a-
|
|
559
|
-
.a-
|
|
548
|
+
.a-table thead { display: none; }
|
|
549
|
+
.a-table, .a-table tbody { display: block; }
|
|
550
|
+
.a-table tr { display: grid; grid-template-columns: minmax(0, 1fr) max-content; border-bottom: 1px solid var(--line); }
|
|
551
|
+
.a-table td { border-bottom: 0; }
|
|
552
|
+
.a-table td.why, .a-table td.acts { grid-column: 1 / -1; padding-top: 0; }
|
|
553
|
+
.a-table .said-line { flex-wrap: wrap; }
|
|
554
|
+
.a-table .said { white-space: normal; } /* the height to read it whole */
|
|
555
|
+
.a-table .said-line > a { max-width: none; }
|
|
560
556
|
}
|
|
561
557
|
@media (prefers-reduced-motion: reduce) {
|
|
562
558
|
* { transition: none !important; }
|
package/ui/bench.js
CHANGED
|
@@ -766,7 +766,8 @@
|
|
|
766
766
|
var dd = $('discover-dialog');
|
|
767
767
|
function price(x) {
|
|
768
768
|
var c = x.cost || {};
|
|
769
|
-
return x.free ? 'free' :
|
|
769
|
+
return x.free ? 'free' : x.free == null ? 'price unknown'
|
|
770
|
+
: (c.input != null ? '$' + c.input + ' in / $' + c.output + ' out per 1M tokens' : 'paid');
|
|
770
771
|
}
|
|
771
772
|
function limits(l) {
|
|
772
773
|
return Object.keys(l || {}).map(function (k) { return Number(l[k]).toLocaleString() + ' ' + k; }).join(' · ');
|
|
@@ -835,13 +836,15 @@
|
|
|
835
836
|
if (res.errors.length) listErrors($('d-errors'), res.errors); // a hub that could not be asked
|
|
836
837
|
var r = res.model, p = $('d-profile'), list = $('d-routes');
|
|
837
838
|
$('d-title').textContent = r.name;
|
|
838
|
-
// Each source a link
|
|
839
|
+
// Each source a link to its page for the model: FreeLLMAPI's where its sitemap has one, models.dev's by its
|
|
840
|
+
// canonical id. The provider's own list has no page, only its connection.
|
|
839
841
|
var sv = $('d-served');
|
|
840
842
|
sv.textContent = r.provider_name + ' · ' + r.model + ' · listed by ';
|
|
841
843
|
r.sources.forEach(function (s, i) {
|
|
842
|
-
var fl = s === 'freellmapi';
|
|
843
844
|
if (i) sv.appendChild(document.createTextNode(' and '));
|
|
844
|
-
sv.appendChild(
|
|
845
|
+
if (s === 'provider') { sv.appendChild(document.createTextNode(r.provider_name + '\'s own list (' + r.connection + ' connection)')); return; }
|
|
846
|
+
var fl = s === 'freellmapi';
|
|
847
|
+
sv.appendChild(el('a', {href: fl ? r.page || 'https://freellmapi.co/models' : r.md_page || 'https://models.dev/',
|
|
845
848
|
target: '_blank', rel: 'noopener'}, fl ? 'FreeLLMAPI' : 'models.dev'));
|
|
846
849
|
});
|
|
847
850
|
if (r.on_bench_as) sv.appendChild(document.createTextNode(' · on the bench as ' + r.on_bench_as));
|
|
@@ -905,6 +908,16 @@
|
|
|
905
908
|
e.preventDefault(); openDiscover(row.dataset.provider, row.dataset.model);
|
|
906
909
|
});
|
|
907
910
|
$('d-close').addEventListener('click', function () { dd.close(); });
|
|
911
|
+
// Refresh lists: every list fetched now (serve.discover_refresh), then the page with them.
|
|
912
|
+
var dr = $('d-refresh');
|
|
913
|
+
if (dr) dr.addEventListener('click', function () {
|
|
914
|
+
var said = $('d-refreshed');
|
|
915
|
+
dr.disabled = true; dr.textContent = 'Fetching…'; said.textContent = 'about half a minute';
|
|
916
|
+
post('/api/discover/refresh', {}).then(function (res) {
|
|
917
|
+
said.textContent = (res.errors.length ? res.errors.join(' · ') + ' · ' : '') + 'Reloading…';
|
|
918
|
+
setTimeout(function () { location.reload(); }, res.errors.length ? 4000 : 500);
|
|
919
|
+
}).catch(function (err) { said.textContent = String(err); dr.disabled = false; dr.textContent = 'Refresh lists'; });
|
|
920
|
+
});
|
|
908
921
|
function follow(log, title, before) {
|
|
909
922
|
runView(title, before + 'Running in the background; closing this does not stop it. Log: runs/' + log);
|
|
910
923
|
timer = setInterval(function () {
|
|
@@ -1331,10 +1344,14 @@
|
|
|
1331
1344
|
head: $('c-form-head'), maxCalls: $('g-max-calls'), maxExp: $('g-max-explorers'),
|
|
1332
1345
|
gErrors: $('g-errors'), gDone: $('g-done'), form: $('c-form'), status: $('c-status'),
|
|
1333
1346
|
del: $('c-delete'), reply: $('c-reply'), merger: $('c-merger'), find: $('c-member-filter'),
|
|
1334
|
-
seats: $('c-seats'), prov: $('c-member-provider')
|
|
1347
|
+
seats: $('c-seats'), prov: $('c-member-provider'), adHoc: $('c-ad-hoc'),
|
|
1348
|
+
ownCalls: $('c-max-calls'), ownExp: $('c-max-explorers')};
|
|
1335
1349
|
var C = null, open = null; // the collection whose details are showing
|
|
1350
|
+
// What the dialog's two limits come to: empty follows the gateway settings.
|
|
1351
|
+
function limit(input, fallback) { return input.value === '' ? fallback : parseInt(input.value, 10); }
|
|
1352
|
+
function maxCalls() { return limit(c.ownCalls, C.settings.max_calls); }
|
|
1336
1353
|
function explorersFor(n) {
|
|
1337
|
-
return Math.max(0, Math.min(
|
|
1354
|
+
return Math.max(0, Math.min(limit(c.ownExp, C.settings.max_explorers), maxCalls() - n));
|
|
1338
1355
|
}
|
|
1339
1356
|
function picked() {
|
|
1340
1357
|
return Array.prototype.filter.call(c.members.querySelectorAll('input'), function (x) { return x.checked; })
|
|
@@ -1356,7 +1373,7 @@
|
|
|
1356
1373
|
return ' · ' + t.name + ' is ticked on ' + t.on.join(' and ') + ': one model agrees with itself, it does not check itself';
|
|
1357
1374
|
}).join('');
|
|
1358
1375
|
c.members.querySelectorAll('input').forEach(function (x) {
|
|
1359
|
-
x.disabled = !x.checked && (n >=
|
|
1376
|
+
x.disabled = !x.checked && (n >= maxCalls() || !!x.dataset.blocked);
|
|
1360
1377
|
});
|
|
1361
1378
|
drawSeats(ex);
|
|
1362
1379
|
}
|
|
@@ -1435,9 +1452,9 @@
|
|
|
1435
1452
|
}
|
|
1436
1453
|
// The member search: the filter boxes' match (any text, case-insensitive),
|
|
1437
1454
|
// over the model's name and each chip's alias, served name and provider,
|
|
1438
|
-
// and the provider select beside it. It only hides chips,
|
|
1439
|
-
//
|
|
1440
|
-
// it
|
|
1455
|
+
// and the provider select beside it. It only hides chips, ticked ones too:
|
|
1456
|
+
// the seats above the list always show what is picked, each with its ×, and
|
|
1457
|
+
// every count still includes it. A row goes when all its chips do.
|
|
1441
1458
|
function filterMembers() {
|
|
1442
1459
|
var q = c.find.value.trim().toLowerCase(), prov = c.prov.value;
|
|
1443
1460
|
c.members.querySelectorAll('.m-row').forEach(function (row) {
|
|
@@ -1446,8 +1463,8 @@
|
|
|
1446
1463
|
var box = lab.querySelector('input');
|
|
1447
1464
|
var text = (box.value + ' ' + (lab.dataset.served || '') + ' ' + (fact(box.value).connection || '') + ' ' +
|
|
1448
1465
|
lab.textContent).toLowerCase();
|
|
1449
|
-
lab.hidden = !box.checked &&
|
|
1450
|
-
|
|
1466
|
+
lab.hidden = !box.checked && !!lab.dataset.paid || !named && text.indexOf(q) === -1
|
|
1467
|
+
|| !!prov && (fact(box.value).provider || '') !== prov;
|
|
1451
1468
|
any = any || !lab.hidden;
|
|
1452
1469
|
});
|
|
1453
1470
|
row.hidden = !any;
|
|
@@ -1483,6 +1500,8 @@
|
|
|
1483
1500
|
function showMerger() { $('c-merger-row').hidden = c.reply.value !== 'merged'; }
|
|
1484
1501
|
c.reply.addEventListener('change', showMerger);
|
|
1485
1502
|
c.shape.addEventListener('change', recount); // the explorer seats say their shape
|
|
1503
|
+
c.ownCalls.addEventListener('input', recount);
|
|
1504
|
+
c.ownExp.addEventListener('input', recount);
|
|
1486
1505
|
// A collection opens in a popup, as every other edit on the dashboard does:
|
|
1487
1506
|
// the one clicked, or a new one from New collection.
|
|
1488
1507
|
function closeForm() {
|
|
@@ -1497,6 +1516,10 @@
|
|
|
1497
1516
|
if (!c.form.open) c.form.showModal();
|
|
1498
1517
|
c.name.value = row ? row.name : ''; c.name.readOnly = !!row;
|
|
1499
1518
|
c.desc.value = row ? row.description : ''; c.tags.value = row ? row.tags.join(', ') : '';
|
|
1519
|
+
c.adHoc.checked = !!row && row.ad_hoc;
|
|
1520
|
+
c.ownCalls.value = row && row.max_calls !== undefined ? row.max_calls : '';
|
|
1521
|
+
c.ownExp.value = row && row.max_explorers !== undefined ? row.max_explorers : '';
|
|
1522
|
+
c.ownCalls.placeholder = C.settings.max_calls; c.ownExp.placeholder = C.settings.max_explorers;
|
|
1500
1523
|
drawTagPicks();
|
|
1501
1524
|
c.members.textContent = ''; c.find.value = '';
|
|
1502
1525
|
c.prov.length = 1; // "Every provider", then each one a model is on
|
|
@@ -1565,6 +1588,7 @@
|
|
|
1565
1588
|
var left = el('div');
|
|
1566
1589
|
var title = el('div', {class: 'top'});
|
|
1567
1590
|
title.appendChild(el('span', {class: 'alias'}, 'bench/' + row.name));
|
|
1591
|
+
if (row.ad_hoc) title.appendChild(el('span', {class: 'chip'}, 'ad hoc: only when called by name'));
|
|
1568
1592
|
row.tags.forEach(function (t) { title.appendChild(el('span', {class: 'chip'}, t)); });
|
|
1569
1593
|
left.appendChild(title);
|
|
1570
1594
|
if (row.description) left.appendChild(el('p', {class: 'desc'}, row.description));
|
|
@@ -1631,7 +1655,9 @@
|
|
|
1631
1655
|
var name = c.name.value.trim().replace(/^bench\//, '');
|
|
1632
1656
|
post('/api/collection', {name: name, members: picked(), shape: c.shape.value, description: c.desc.value,
|
|
1633
1657
|
tags: c.tags.value.split(','), reply: c.reply.value,
|
|
1634
|
-
merger: c.merger.value
|
|
1658
|
+
merger: c.merger.value, ad_hoc: c.adHoc.checked,
|
|
1659
|
+
max_calls: c.ownCalls.value === '' ? null : Number(c.ownCalls.value),
|
|
1660
|
+
max_explorers: c.ownExp.value === '' ? null : Number(c.ownExp.value)}).then(function (r) {
|
|
1635
1661
|
if (!r.ok) { listErrors(c.errors, r.errors); return; }
|
|
1636
1662
|
finished('Saved bench/' + name + '.');
|
|
1637
1663
|
}).catch(function (err) { c.errors.textContent = String(err); });
|