@wendellresearch-ai/benchwarmers 0.5.2 → 0.5.3
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/USAGE.md +16 -9
- package/VERSION +1 -1
- package/package.json +1 -1
- package/scripts/auto_retire.py +5 -2
- package/scripts/test_serve_edit.py +1 -1
- package/serve.py +166 -74
- package/suite/run.py +3 -0
- package/ui/bench.css +50 -13
- package/ui/bench.js +20 -24
package/USAGE.md
CHANGED
|
@@ -25,6 +25,7 @@ coding agent doing it with you); the gateway's whole contract is in
|
|
|
25
25
|
| Group | Page | What it is for |
|
|
26
26
|
| --- | --- | --- |
|
|
27
27
|
| Observe | **At a glance** | Every model on the bench: whether it works right now, its role, and its connection. Click a name for its popup: role, alias, facts, options, retire. |
|
|
28
|
+
| | **Action needed** | What will not fix itself, by the connection it comes from, with Retry and Retire on each model. |
|
|
28
29
|
| | **Benchmarks** | How models do on the suite and on real work: bugs found against false alarms, who is confidently wrong, and speed. |
|
|
29
30
|
| | **Quota management** | A tile per provider: what is left, when it resets, and which of its models are resting. Then the week's resets, and what the bench sent. |
|
|
30
31
|
| | **Logs** | Gateway calls (who sat and how it went, never the prompt), changes made from the dashboard, the scheduler, and onboarding runs. |
|
|
@@ -35,15 +36,21 @@ coding agent doing it with you); the gateway's whole contract is in
|
|
|
35
36
|
| Control | **Settings** | Theme, gateway keys, the scheduler, the model client, providers whose free models work only in their own app, how the install is reached (shown only), and Restart. |
|
|
36
37
|
| | **Info** | Version, Python, data folder and counts. |
|
|
37
38
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
39
|
+
**Action needed** lists what will not fix itself, such as a model about to be
|
|
40
|
+
retired, grouped by the connection it comes from, since one cause usually
|
|
41
|
+
reaches every model there. Each model is one line: its state, the provider's
|
|
42
|
+
own words for the error (whole on hover), and Retry (one call; an answer
|
|
43
|
+
clears the alert) and Retire. A group of two or more has Retry all and Retire
|
|
44
|
+
all; a connection held because every model on it fails says so above its
|
|
45
|
+
models, with any link the provider gave. Automatic retirements from the last
|
|
46
|
+
week are folded at the end, each with Restore. Its count sits beside it in the
|
|
47
|
+
sidebar, and At a glance opens on one line saying what is waiting and how
|
|
48
|
+
soon. An alert whose models have all been retired goes at once. A model that
|
|
49
|
+
is out of quota is not counting down: days the bench held its calls back until
|
|
50
|
+
a stated reset, an account's shared free quota was spent, or the provider said
|
|
51
|
+
the model's free quota is used up, pause its clock, and while it is still out
|
|
52
|
+
of quota no countdown shows. One refused again after the short wait it named
|
|
53
|
+
is failing, and keeps counting.
|
|
47
54
|
|
|
48
55
|
**Restart** (Settings, Server and access), or `bench restart`, starts the
|
|
49
56
|
dashboard again on the code now on disk, after an update. It waits for
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.5.
|
|
1
|
+
0.5.3
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wendellresearch-ai/benchwarmers",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.3",
|
|
4
4
|
"description": "Self-hosted panel gateway for LLMs: ask a collection of models at once, keep every answer side by side, rotate explorer models in, and let your verdicts pick the next panel.",
|
|
5
5
|
"mcpName": "io.github.WendellResearch-AI/benchwarmers",
|
|
6
6
|
"license": "MIT",
|
package/scripts/auto_retire.py
CHANGED
|
@@ -77,13 +77,15 @@ def account_quota(detail: str) -> bool:
|
|
|
77
77
|
|
|
78
78
|
|
|
79
79
|
HELD = "not sent; paced by the bench" # metrics/pacing.py's refusal
|
|
80
|
+
USED_UP = ("free usage quota",) # xKiro: "...quota for this model is used up"
|
|
80
81
|
|
|
81
82
|
|
|
82
83
|
def tapped_out(detail: str) -> bool:
|
|
83
84
|
"""Out of quota right now, so the model could not be tried: the bench held
|
|
84
85
|
the call back (a limit spent until its reset, the provider's or a cap you
|
|
85
|
-
set),
|
|
86
|
-
|
|
86
|
+
set), the account's shared quota is spent (account_quota), or the
|
|
87
|
+
provider says the model's own free quota is used up."""
|
|
88
|
+
return HELD in detail or account_quota(detail) or any(m in detail for m in USED_UP)
|
|
87
89
|
|
|
88
90
|
|
|
89
91
|
def plan(today: dt.date, models: dict[str, str], live: set[str],
|
|
@@ -378,6 +380,7 @@ def _selfcheck() -> None:
|
|
|
378
380
|
held = [dict(row("luna", n, "capacity"), detail=f"rate limit ({HELD}): 0 left") for n in (9, 5, 0)]
|
|
379
381
|
p = plan(d, {"luna": "c/luna"}, {"c/luna"}, {}, held, {}, {}, {"c/luna": "c"})
|
|
380
382
|
assert not p["retire"] and not p["pending"], p
|
|
383
|
+
assert tapped_out("Error code: service_unavailable - The free usage quota for this model is used up")
|
|
381
384
|
# real failures, then out of quota for a week: those days pause the clock
|
|
382
385
|
paused = ([row("p", n, "capacity") for n in (10, 9, 8, 0)]
|
|
383
386
|
+ [dict(row("p", n, "capacity"), detail=HELD) for n in range(1, 8)])
|
|
@@ -454,7 +454,7 @@ def main() -> int:
|
|
|
454
454
|
encoding="utf-8")
|
|
455
455
|
serve.DATA, serve.registered = pathlib.Path(tmp), lambda: {"h1-served": "h1"}
|
|
456
456
|
try:
|
|
457
|
-
shown = serve.
|
|
457
|
+
shown = serve.actions_page(*serve.open_actions())
|
|
458
458
|
finally:
|
|
459
459
|
serve.DATA, serve.registered = kept_data, kept_reg
|
|
460
460
|
ok = "here.example" in shown and "gone.example" not in shown and "old retires" not in shown
|
package/serve.py
CHANGED
|
@@ -2003,91 +2003,172 @@ def first_run() -> str:
|
|
|
2003
2003
|
f"<button class='btn primary' id='first-go'>{label}</button></div></div></section>")
|
|
2004
2004
|
|
|
2005
2005
|
|
|
2006
|
-
def
|
|
2007
|
-
"""
|
|
2006
|
+
def said(detail: str) -> tuple[str, str]:
|
|
2007
|
+
"""An error as one short line, and the link the provider put in it: the
|
|
2008
|
+
status code and the provider's own words, without the JSON around them or
|
|
2009
|
+
a lead-in such as "Too many requests." that says less than the sentence
|
|
2010
|
+
after it. The whole detail stays in the row's title."""
|
|
2011
|
+
code = re.search(r"Error code: (\w+)", detail)
|
|
2012
|
+
quoted = re.search(r"""['"](?:message|detail)['"]: ['"](.+?)(?:['"]\s*[,}]|$)""", detail)
|
|
2013
|
+
text = (quoted.group(1) if quoted else re.sub(r"^.*?Error: ", "", detail) if "Error: " in detail
|
|
2014
|
+
else re.sub(r"^\w+: ", "", detail))
|
|
2015
|
+
text = " ".join(text.replace("<<", "").replace(">>", "").split())
|
|
2016
|
+
first, _, rest = text.partition(". ")
|
|
2017
|
+
if rest and len(first) < 25:
|
|
2018
|
+
text = rest
|
|
2019
|
+
url = re.search(r"https?://[^\s;,'\"()]+", quoted.group(1) if quoted else "")
|
|
2020
|
+
text = (f"{code.group(1)} · " if code else "") + text
|
|
2021
|
+
return (text if len(text) <= 110 else text[:109].rstrip() + "…"), (url.group(0) if url else "")
|
|
2022
|
+
|
|
2023
|
+
|
|
2024
|
+
def open_actions() -> tuple[dict[str, dict], list[tuple[str, dict]]]:
|
|
2025
|
+
"""What needs a person, by the connection it comes from ({connection:
|
|
2026
|
+
{"held": [...], "rows": [...]}}, the soonest to retire first), and the
|
|
2027
|
+
last week's automatic retirements, newest first.
|
|
2008
2028
|
|
|
2009
2029
|
Rate limits and timeouts are not listed: they resolve themselves, or count
|
|
2010
2030
|
toward the 7-day backstop and appear here as `retiring` when that nears.
|
|
2011
|
-
Automatic retirements
|
|
2012
|
-
|
|
2031
|
+
Automatic retirements ARE listed, with Restore, because a decision taken
|
|
2032
|
+
without a person should be easy for one to undo."""
|
|
2013
2033
|
alerts = load_json(DATA / "metrics" / "alerts.json")
|
|
2014
2034
|
names = registered()
|
|
2015
|
-
today = datetime.date.today()
|
|
2016
|
-
KIND = {"retiring": "err", "retired": "err", "connection": "warn",
|
|
2017
|
-
"registration": "warn", "plan": "warn"}
|
|
2018
|
-
items = []
|
|
2019
2035
|
live = set(names.values())
|
|
2020
|
-
|
|
2036
|
+
status = model_status()
|
|
2037
|
+
groups: dict[str, dict] = {}
|
|
2038
|
+
|
|
2039
|
+
def group(conn: str) -> dict:
|
|
2040
|
+
return groups.setdefault(conn or "?", {"held": [], "rows": {}})
|
|
2041
|
+
|
|
2042
|
+
def row(alias: str, key: str, a: dict) -> dict:
|
|
2043
|
+
# The roster's words for its state, so the two pages agree; the
|
|
2044
|
+
# alert's own evidence for a countdown, the latest probe otherwise.
|
|
2045
|
+
label, css, latest = status.get(alias, (a.get("kind", "?"), "warn", ""))
|
|
2046
|
+
days = re.search(r"in (\d+)d", label)
|
|
2047
|
+
detail = a.get("action", "") if a.get("kind") == "retiring" else latest or a.get("message", "")
|
|
2048
|
+
text, url = said(detail)
|
|
2049
|
+
return {"alias": alias, "key": key, "label": label, "css": "warn" if days and int(days.group(1)) > 2 else css,
|
|
2050
|
+
"days": int(days.group(1)) if days else 99, "said": text, "url": url, "title": detail,
|
|
2051
|
+
"why": a.get("message", "")}
|
|
2052
|
+
|
|
2053
|
+
for key, a in alerts.items():
|
|
2021
2054
|
served = key.split(":", 2)[2] if key.count(":") >= 2 else ""
|
|
2022
2055
|
alias = a.get("alias") or names.get(served, "")
|
|
2023
2056
|
# The alerts file is rewritten by the daily check; until then an alert
|
|
2024
2057
|
# whose models have all left the bench (Retire all, or one by one) has
|
|
2025
2058
|
# nothing left to act on.
|
|
2026
|
-
if a.get("aliases") and not live & set(a["aliases"]):
|
|
2027
|
-
continue
|
|
2028
|
-
if a.get("kind") == "retiring" and alias and alias not in live:
|
|
2059
|
+
if (a.get("aliases") and not live & set(a["aliases"])) or (alias and alias not in live):
|
|
2029
2060
|
continue
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
acts.append(f"<button class='btn small' data-act='open' data-alias='{esc(alias)}'>Open</button>")
|
|
2050
|
-
acts.append(f"<button class='btn small danger' data-act='retire' data-alias='{esc(alias)}'"
|
|
2051
|
-
f" data-why='{esc(a.get('message', ''))}'>Retire now</button>")
|
|
2052
|
-
if a.get("dismiss"): # a notice, not a state: it goes once seen
|
|
2053
|
-
acts.append(f"<button class='btn small' data-act='dismiss' data-key='{esc(key)}'>Got it</button>")
|
|
2054
|
-
items.append((key, a.get("kind", "?"), KIND.get(a.get("kind"), "warn"),
|
|
2055
|
-
a.get("message", ""), why, "".join(acts)))
|
|
2056
|
-
cutoff = (today - datetime.timedelta(days=7)).isoformat()
|
|
2057
|
-
tags = load_json(DATA / "config" / "provider-tags.json") # a retired model's connection, by served name
|
|
2058
|
-
auto = [(f"retired:{alias}:{r['at']}", "", "",
|
|
2059
|
-
f"{alias} · {r.get('served') or '?'} via {tags.get(r.get('served', ''), '').strip('?') or r.get('endpoint') or endpoints.default()}"
|
|
2060
|
-
f" · {r['at'][:10]}",
|
|
2061
|
-
r.get("why", "").removeprefix("auto: "),
|
|
2062
|
-
f"<button class='btn small' data-act='restore' data-alias='{esc(alias)}'>Restore</button>")
|
|
2063
|
-
for alias, r in sorted(retired().items(), key=lambda kv: kv[1].get("at", ""), reverse=True)
|
|
2061
|
+
if a.get("kind") == "connection":
|
|
2062
|
+
g = group(served)
|
|
2063
|
+
text, url = said(a.get("action", ""))
|
|
2064
|
+
g["held"].append({"said": text, "url": url, "title": a.get("action", "")})
|
|
2065
|
+
for held in a.get("aliases", []):
|
|
2066
|
+
if held in live:
|
|
2067
|
+
g["rows"].setdefault(held, row(held, key, a))
|
|
2068
|
+
elif alias: # its own alert says more than a held connection's list
|
|
2069
|
+
group((tag_for(alias) or a.get("endpoint") or "?").split("/")[0])["rows"][alias] = row(alias, key, a)
|
|
2070
|
+
else: # a notice about no one model (NVIDIA's limits)
|
|
2071
|
+
group("Notices")["rows"][key] = {"alias": "", "key": key, "label": a.get("kind", "?"), "css": "warn",
|
|
2072
|
+
"days": 99, "said": a.get("message", ""), "url": "",
|
|
2073
|
+
"title": a.get("action", ""), "dismiss": a.get("dismiss")}
|
|
2074
|
+
for g in groups.values():
|
|
2075
|
+
g["rows"] = sorted(g["rows"].values(), key=lambda r: (r["days"], r["alias"]))
|
|
2076
|
+
order = sorted(groups.items(), key=lambda kv: (kv[0] == "Notices", min([r["days"] for r in kv[1]["rows"]] or [99]),
|
|
2077
|
+
-len(kv[1]["rows"]), kv[0]))
|
|
2078
|
+
cutoff = (datetime.date.today() - datetime.timedelta(days=7)).isoformat()
|
|
2079
|
+
auto = [(alias, r) for alias, r in sorted(retired().items(), key=lambda kv: kv[1].get("at", ""), reverse=True)
|
|
2064
2080
|
if r.get("by") == "auto" and r.get("at", "") >= cutoff]
|
|
2065
|
-
|
|
2081
|
+
return dict(order), auto
|
|
2082
|
+
|
|
2083
|
+
|
|
2084
|
+
def open_count(groups: dict) -> int:
|
|
2085
|
+
"""Models and connections waiting on a person: the sidebar's count."""
|
|
2086
|
+
return sum(len(g["rows"]) + len(g["held"]) for g in groups.values())
|
|
2087
|
+
|
|
2088
|
+
|
|
2089
|
+
def and_list(parts: list[str]) -> str:
|
|
2090
|
+
return " and ".join(parts) if len(parts) < 3 else ", ".join(parts[:-1]) + " and " + parts[-1]
|
|
2091
|
+
|
|
2092
|
+
|
|
2093
|
+
def actions_line(groups: dict) -> str:
|
|
2094
|
+
"""At a glance's one line about Action needed, when anything is open:
|
|
2095
|
+
what is waiting and how soon, and the way to its page."""
|
|
2096
|
+
rows = [r for g in groups.values() for r in g["rows"]]
|
|
2097
|
+
held = sum(len(g["held"]) for g in groups.values())
|
|
2098
|
+
if not rows and not held:
|
|
2066
2099
|
return ""
|
|
2100
|
+
models = sum(1 for r in rows if r["alias"])
|
|
2101
|
+
what = and_list([f"{n} {word}{'s' * (n != 1)}" for n, word in
|
|
2102
|
+
((models, "model"), (held, "connection"), (len(rows) - models, "notice")) if n])
|
|
2103
|
+
soon = min(r["days"] for r in rows) if rows else 99
|
|
2104
|
+
many = sum(r["days"] == soon for r in rows)
|
|
2105
|
+
when = (f"; {many} retire{'s' * (many == 1)} in {soon} day{'s' * (soon != 1)}" if soon < 99 else "")
|
|
2106
|
+
return (f"<p class='a-line'><strong>Action needed</strong> <span>{what}{when}.</span>"
|
|
2107
|
+
" <button type='button' class='linkish' data-go='needs'>Review →</button></p>")
|
|
2108
|
+
|
|
2109
|
+
|
|
2110
|
+
def actions_page(groups: dict, auto: list) -> str:
|
|
2111
|
+
"""Action needed, its own page since 2026-11-02: it sat above every page
|
|
2112
|
+
and grew to a screen and more of red. Grouped by connection, because
|
|
2113
|
+
one cause usually reaches every model on it (nine models on one free
|
|
2114
|
+
tier's rate limit); each model one line, its error in the provider's own
|
|
2115
|
+
words, with Retry a click away."""
|
|
2116
|
+
def link(url: str) -> str:
|
|
2117
|
+
if not url:
|
|
2118
|
+
return ""
|
|
2119
|
+
shown = re.sub(r"^https?://", "", url).split("?")[0]
|
|
2120
|
+
return f" <a href='{esc(url)}' target='_blank' rel='noopener'>{esc(shown)} ↗</a>"
|
|
2121
|
+
|
|
2122
|
+
def model_row(r: dict) -> str:
|
|
2123
|
+
if not r["alias"]: # a notice: what it says, and Got it if it goes once seen
|
|
2124
|
+
got = (f"<button class='linkish' data-act='dismiss' data-key='{esc(r['key'])}'>Got it</button>"
|
|
2125
|
+
if r.get("dismiss") else "")
|
|
2126
|
+
return (f"<div class='a-row' data-row><span class='what'>{esc(r['said'])}</span>"
|
|
2127
|
+
f"<span>{chip(r['label'], r['css'])}</span><span class='why'>{esc(r['title'])}</span>"
|
|
2128
|
+
f"<span class='acts'>{got}</span></div>")
|
|
2129
|
+
alias = esc(r["alias"])
|
|
2130
|
+
return (f"<div class='a-row' data-row><a href='#' class='model' data-alias='{alias}'>"
|
|
2131
|
+
f"<span class='alias'>{alias}</span></a><span>{chip(r['label'], r['css'])}</span>"
|
|
2132
|
+
f"<span class='why' title='{esc(r['title'])}'><span class='said'>{esc(r['said'])}</span>{link(r['url'])}</span>"
|
|
2133
|
+
f"<span class='acts'><button class='linkish' data-act='retry' data-alias='{alias}'"
|
|
2134
|
+
" title='One call now; an answer clears the alert'>Retry</button>"
|
|
2135
|
+
f"<button class='linkish danger' data-act='retire' data-alias='{alias}'"
|
|
2136
|
+
f" data-why='{esc(r['why'] or r['label'])}'>Retire</button></span></div>")
|
|
2137
|
+
|
|
2138
|
+
def section(conn: str, g: dict) -> str:
|
|
2139
|
+
aliases = [r["alias"] for r in g["rows"] if r["alias"]]
|
|
2140
|
+
both = (f"<button class='btn small' data-act='retry' data-aliases='{esc(' '.join(aliases))}'"
|
|
2141
|
+
f" title='One call each'>Retry all {len(aliases)}</button>"
|
|
2142
|
+
f"<button class='btn small danger' data-act='retire' data-aliases='{esc(' '.join(aliases))}'"
|
|
2143
|
+
f" data-why='every model on {esc(conn)} needed action'>Retire all {len(aliases)}</button>"
|
|
2144
|
+
if len(aliases) > 1 else "")
|
|
2145
|
+
held = "".join(
|
|
2146
|
+
f"<div class='a-held' title='{esc(h['title'])}'><span class='chip'>held</span><span>Every model"
|
|
2147
|
+
f" here is failing, likely the connection (key, credit, prefix), so nothing was retired."
|
|
2148
|
+
f" Latest: {esc(h['said'])}{link(h['url'])}</span></div>" for h in g["held"])
|
|
2149
|
+
n = len(g["rows"])
|
|
2150
|
+
return (f"<div class='a-group'><div class='a-head' data-row><span class='conn'>{esc(conn)}</span>"
|
|
2151
|
+
f"<span class='count'>{n} {'notice' if conn == 'Notices' else 'model'}{'s' * (n != 1)}</span>"
|
|
2152
|
+
f"<span class='why'></span><span class='acts'>{both}</span></div>{held}"
|
|
2153
|
+
f"<div class='a-rows'>{''.join(model_row(r) for r in g['rows'])}</div></div>")
|
|
2067
2154
|
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
#
|
|
2078
|
-
folded = (f"<details><summary>Retired automatically this week · {len(auto)}"
|
|
2079
|
-
" <span class='muted'>— each can be restored</span></summary>"
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
f"
|
|
2085
|
-
"<span class='sep' aria-hidden='true'>·</span>"
|
|
2086
|
-
"<button type='button' class='linkish' id='actions-hide' aria-expanded='false'"
|
|
2087
|
-
" title='Open it here for 12 hours'>show</button></div>"
|
|
2088
|
-
+ ("<p>States that will not fix themselves.</p>" if items
|
|
2089
|
-
else "<p>Nothing open.</p>")
|
|
2090
|
-
+ f"{render_rows(items)}{folded}</section>")
|
|
2155
|
+
tags = load_json(DATA / "config" / "provider-tags.json") # a retired model's connection, by served name
|
|
2156
|
+
gone = "".join(
|
|
2157
|
+
f"<div class='a-row' data-row><a href='#' class='model' data-alias='{esc(alias)}'>"
|
|
2158
|
+
f"<span class='alias'>{esc(alias)}</span></a><span class='when'>{esc(r['at'][:10])}</span>"
|
|
2159
|
+
f"<span class='why' title='{esc(r.get('why', ''))}'><span class='said'>"
|
|
2160
|
+
f"{esc(tags.get(r.get('served', ''), '').strip('?') or r.get('endpoint') or endpoints.default())}"
|
|
2161
|
+
f" · {esc(said(r.get('why', '').removeprefix('auto: '))[0])}</span></span>"
|
|
2162
|
+
f"<span class='acts'><button class='linkish' data-act='restore' data-alias='{esc(alias)}'>Restore</button></span></div>"
|
|
2163
|
+
for alias, r in auto)
|
|
2164
|
+
# Folded: a week of automatic retirements is a record to check, not a list of things to do.
|
|
2165
|
+
folded = (f"<details class='a-retired'><summary>Retired automatically this week · {len(auto)}"
|
|
2166
|
+
f" <span class='muted'>— each can be restored</span></summary><div class='a-rows'>{gone}</div></details>"
|
|
2167
|
+
if auto else "")
|
|
2168
|
+
body = "".join(section(conn, g) for conn, g in groups.items()) or "<p class='a-none'>Nothing open.</p>"
|
|
2169
|
+
return ("<div class='panel'><h2>Action needed</h2><p class='sub'>States that will not fix themselves, by the"
|
|
2170
|
+
" connection they come from. A reason's full error is on hover, and a name opens the model."
|
|
2171
|
+
f" Retry spends one call; an answer clears the alert.</p>{body}{folded}</div>")
|
|
2091
2172
|
|
|
2092
2173
|
|
|
2093
2174
|
CAP_LABELS = {"tools": "tools", "vision": "vision", "structured_output": "json", "reasoning": "reasons"}
|
|
@@ -2833,7 +2914,7 @@ def render(theme: str | None = None) -> str:
|
|
|
2833
2914
|
"<button class='btn primary' id='onboard-open'>Onboard a model</button></div></header>"
|
|
2834
2915
|
# What needs a person first, then the pages; endpoints are managed from
|
|
2835
2916
|
# Connections now (the header button moved there).
|
|
2836
|
-
f"<main>{first_run()}
|
|
2917
|
+
f"<main>{first_run()}<div class='layout'>{tabs()}</div></main>{editor()}"
|
|
2837
2918
|
f"<script>window.BENCH={directory_json()};</script>"
|
|
2838
2919
|
# Observable Plot (on d3) draws the Benchmarks charts. Pinned versions from
|
|
2839
2920
|
# jsDelivr; without a network the tables under each chart still work.
|
|
@@ -3235,6 +3316,7 @@ def collections_tab() -> str:
|
|
|
3235
3316
|
# pages were too many for a row of tabs. Collections holds the gateway's
|
|
3236
3317
|
# requests under the collections they ran; Benchmarks holds Performance.
|
|
3237
3318
|
SECTIONS = (("Observe", (("glance", "At a glance", "roster"),
|
|
3319
|
+
("needs", "Action needed", "actions_page"),
|
|
3238
3320
|
("suite", "Benchmarks", "benchmarks_tab"),
|
|
3239
3321
|
("quotas", "Quota management", "quotas_page"),
|
|
3240
3322
|
("logs", "Logs", "logs_page"))),
|
|
@@ -3253,11 +3335,21 @@ def collections_and_gateway() -> str:
|
|
|
3253
3335
|
|
|
3254
3336
|
|
|
3255
3337
|
def tabs() -> str:
|
|
3338
|
+
# Read once a render: Action needed's page, its count in the sidebar and
|
|
3339
|
+
# At a glance's line about it.
|
|
3340
|
+
groups, auto = open_actions()
|
|
3341
|
+
count = {"needs": open_count(groups)}
|
|
3342
|
+
page = {"needs": lambda: actions_page(groups, auto), "glance": lambda: actions_line(groups) + roster()}
|
|
3343
|
+
|
|
3344
|
+
def button(key: str, label: str) -> str:
|
|
3345
|
+
n = count.get(key)
|
|
3346
|
+
return (f"<button role='tab' data-tab='{key}' aria-selected='false'"
|
|
3347
|
+
+ (f" aria-label='{esc(label)}, {n} open'>{esc(label)}<span class='n' aria-hidden='true'>{n}</span>"
|
|
3348
|
+
if n else f">{esc(label)}") + "</button>")
|
|
3256
3349
|
nav = "".join(f"<div class='group'><h2>{esc(group)}</h2>"
|
|
3257
|
-
+ "".join(
|
|
3258
|
-
for key, label, _ in pages) + "</div>"
|
|
3350
|
+
+ "".join(button(key, label) for key, label, _ in pages) + "</div>"
|
|
3259
3351
|
for group, pages in SECTIONS)
|
|
3260
|
-
panes = "".join(f"<section class='tab' id='tab-{key}'>{globals()[fn]()}</section>"
|
|
3352
|
+
panes = "".join(f"<section class='tab' id='tab-{key}'>{page[key]() if key in page else globals()[fn]()}</section>"
|
|
3261
3353
|
for key, _, fn in TABS)
|
|
3262
3354
|
return f"<nav class='tabs' role='tablist' aria-label='Pages'>{nav}</nav><div class='panes'>{panes}</div>"
|
|
3263
3355
|
|
package/suite/run.py
CHANGED
|
@@ -632,6 +632,9 @@ CAPACITY = (
|
|
|
632
632
|
# way, filed as `other`, which the 3-day auto-retire rule would act on.
|
|
633
633
|
"request limit reached",
|
|
634
634
|
"resourceexhausted",
|
|
635
|
+
# xKiro: "The free usage quota for this model is used up" (503). Out of
|
|
636
|
+
# quota, not gone; filed as `other` it was 3 days from auto-retire.
|
|
637
|
+
"free usage quota",
|
|
635
638
|
)
|
|
636
639
|
|
|
637
640
|
|
package/ui/bench.css
CHANGED
|
@@ -99,33 +99,57 @@ button { font: inherit; cursor: pointer; }
|
|
|
99
99
|
/* ---- layout --------------------------------------------------------- */
|
|
100
100
|
main { max-width: var(--page, 104rem); margin: 0 auto; padding: 0 var(--gutter) 4rem; }
|
|
101
101
|
|
|
102
|
-
/*
|
|
102
|
+
/* first run: a fresh install's next step, above every page until it is done */
|
|
103
103
|
.problems { background: var(--sheet, var(--panel)); border-radius: 14px; padding: 1rem 1.4rem; margin: .5rem 0 1.5rem;
|
|
104
104
|
box-shadow: 0 1px 2px rgb(0 0 0 / .04), 0 12px 32px -14px rgb(0 0 0 / .14);
|
|
105
105
|
-webkit-backdrop-filter: var(--glass, none); backdrop-filter: var(--glass, none); }
|
|
106
106
|
.problems h2 { margin: 0 0 .25rem; font-size: 1.25rem; font-weight: 700; }
|
|
107
|
-
.problems > p { margin: 0 0 .5rem; color: var(--muted); font-size: .9375rem; }
|
|
108
107
|
.problem { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .25rem 1rem;
|
|
109
108
|
align-items: center; padding: .55rem .75rem; margin: 0 -.75rem; border-radius: var(--radius); }
|
|
110
109
|
.problem:hover { background: var(--step-1); }
|
|
111
|
-
.problems details { margin-top: .5rem; }
|
|
112
|
-
.problems details > summary { cursor: pointer; font-weight: 500; padding: .4rem 0; }
|
|
113
|
-
.problems details > summary .muted { font-weight: 400; }
|
|
114
|
-
.problem .what { font-weight: 500; }
|
|
115
110
|
.problem .why { color: var(--muted); font-size: .875rem; overflow-wrap: anywhere; }
|
|
116
111
|
.problem .acts, .endpoint .acts, .ep .acts { display: flex; gap: .5rem; flex-wrap: wrap; justify-content: flex-end; }
|
|
117
112
|
.row-input { display: flex; gap: .5rem; align-items: center; margin-top: .35rem; }
|
|
118
113
|
.row-input input { flex: 1; min-width: 0; margin: 0 !important; }
|
|
119
114
|
.sub-head { font-weight: 600; margin: 1rem 0 .25rem; }
|
|
120
|
-
.problem .chip { margin-right: .4rem; }
|
|
121
115
|
.problems .head { display: flex; align-items: baseline; gap: .5rem; }
|
|
122
116
|
.problems .head h2 { margin-bottom: .25rem; }
|
|
123
|
-
|
|
124
|
-
/*
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
.
|
|
128
|
-
.
|
|
117
|
+
|
|
118
|
+
/* Action needed: its own page, by connection. Each model is one line of a
|
|
119
|
+
grid shared by its group (name, state, the provider's words, actions), so
|
|
120
|
+
the columns line up down the group. */
|
|
121
|
+
.a-line { display: flex; flex-wrap: wrap; align-items: baseline; gap: .25rem .6rem; margin: 1.4rem 1.6rem 0;
|
|
122
|
+
padding: .45rem .75rem; background: var(--err-ground); border-radius: var(--radius); }
|
|
123
|
+
.a-group { padding: 1rem 0 .75rem; border-top: 1px solid var(--line); }
|
|
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); }
|
|
128
|
+
.a-head .why { font-size: .875rem; }
|
|
129
|
+
.a-head .why:empty { display: none; }
|
|
130
|
+
.a-head .acts { margin-left: auto; display: flex; gap: .5rem; }
|
|
131
|
+
.a-held { display: flex; gap: .6rem; align-items: baseline; margin: .35rem 0 .6rem; padding: .5rem .75rem;
|
|
132
|
+
background: var(--warn-ground); border-radius: var(--radius); font-size: .9375rem; overflow-wrap: anywhere; }
|
|
133
|
+
.a-held .chip { flex: none; background: var(--ink); color: var(--paper); }
|
|
134
|
+
.a-held a { color: var(--ink); font-weight: 500; }
|
|
135
|
+
.a-rows { display: grid; grid-template-columns: minmax(10rem, max-content) max-content minmax(0, 1fr) max-content;
|
|
136
|
+
gap: 0 1rem; align-items: baseline; }
|
|
137
|
+
.a-row { display: contents; }
|
|
138
|
+
.a-row > * { padding: .3rem 0; border-top: 1px solid color-mix(in oklab, var(--line) 55%, transparent); }
|
|
139
|
+
.a-row:first-child > * { border-top: 0; }
|
|
140
|
+
.a-row .alias, .a-row .what { font-weight: 500; }
|
|
141
|
+
.a-row .what { max-width: 24rem; } /* a notice's sentence wraps, leaving its detail room */
|
|
142
|
+
.a-row .why, .a-row .when { color: var(--muted); font-size: .875rem; overflow-wrap: anywhere; }
|
|
143
|
+
/* One line a model: the provider's words cut to the column, whole on hover; its link stays whole. */
|
|
144
|
+
.a-row .why:has(> .said) { display: flex; gap: .5rem; min-width: 0; }
|
|
145
|
+
.a-row .said { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
146
|
+
.a-row .why > a { flex: none; }
|
|
147
|
+
.a-row .acts { display: flex; gap: .9rem; font-size: .875rem; }
|
|
148
|
+
.linkish.danger { color: var(--err); }
|
|
149
|
+
.a-retired { margin-top: .75rem; border-top: 1px solid var(--line); padding-top: .5rem; }
|
|
150
|
+
.a-retired > summary { cursor: pointer; font-weight: 500; padding: .35rem 0; }
|
|
151
|
+
.a-retired > summary .muted { color: var(--muted); font-weight: 400; }
|
|
152
|
+
.a-none { color: var(--muted); }
|
|
129
153
|
|
|
130
154
|
/* pages: a grouped sidebar (Observe, Gateway, Control); the group names and
|
|
131
155
|
the active page carry the theme's brand colour. A theme with a floor backs
|
|
@@ -156,6 +180,12 @@ main { max-width: var(--page, 104rem); margin: 0 auto; padding: 0 var(--gutter)
|
|
|
156
180
|
content: ""; position: absolute; left: calc(100% + .3em); top: -.05em; width: .75em; height: .75em;
|
|
157
181
|
background: var(--brand);
|
|
158
182
|
clip-path: polygon(50% 0, 62% 38%, 100% 50%, 62% 62%, 50% 100%, 38% 62%, 0 50%, 38% 38%); }
|
|
183
|
+
/* A page's count (Action needed): hung outside the button like the sparkle,
|
|
184
|
+
so the active page's stroke runs under the word and not the number. */
|
|
185
|
+
.tabs button:has(> .n) { position: relative; }
|
|
186
|
+
.tabs button .n { position: absolute; left: calc(100% + .4rem); top: 50%; transform: translateY(-50%);
|
|
187
|
+
padding: .05rem .4rem; border-radius: 999px; font: 700 .75rem/1.25 var(--font-mono);
|
|
188
|
+
background: var(--err-ground); color: var(--err); }
|
|
159
189
|
.tab { display: none; } .tab.active { display: block; }
|
|
160
190
|
/* One surface for the page's content: its panels are sections of it,
|
|
161
191
|
divided by a hairline, rather than separate boxes with gaps between. A
|
|
@@ -170,6 +200,7 @@ main { max-width: var(--page, 104rem); margin: 0 auto; padding: 0 var(--gutter)
|
|
|
170
200
|
.tabs { position: static; flex-direction: row; flex-wrap: wrap; gap: .5rem 1.5rem; }
|
|
171
201
|
.tabs .group { flex-direction: row; flex-wrap: wrap; align-items: baseline; gap: .1rem 1rem; }
|
|
172
202
|
.tabs h2 { margin: 0; }
|
|
203
|
+
.tabs button:has(> .n) { margin-right: 2rem; } /* room for its count in a row */
|
|
173
204
|
}
|
|
174
205
|
|
|
175
206
|
/* settings, connections, logs, info */
|
|
@@ -510,6 +541,12 @@ dialog .pick { display: flex; gap: 1.5rem; }
|
|
|
510
541
|
.toolbar input[type=search] { min-width: 0; width: 100%; }
|
|
511
542
|
button.c-row { grid-template-columns: minmax(0, 1fr); } /* the seats get the width; the whole row opens */
|
|
512
543
|
.c-row .open-hint { display: none; }
|
|
544
|
+
.a-line { margin: 1rem 1rem 0; }
|
|
545
|
+
.a-rows { grid-template-columns: minmax(0, 1fr) max-content; } /* name and state; the words and actions under them */
|
|
546
|
+
.a-row .why, .a-row .acts { grid-column: 1 / -1; padding-top: 0; border-top: 0; }
|
|
547
|
+
.a-row .acts { padding-bottom: .5rem; }
|
|
548
|
+
.a-row .why:has(> .said) { flex-wrap: wrap; }
|
|
549
|
+
.a-row .said { white-space: normal; } /* a phone has the height to read it whole */
|
|
513
550
|
}
|
|
514
551
|
@media (prefers-reduced-motion: reduce) {
|
|
515
552
|
* { transition: none !important; }
|
package/ui/bench.js
CHANGED
|
@@ -123,23 +123,10 @@
|
|
|
123
123
|
c.addEventListener(c.tagName === 'SELECT' ? 'change' : 'input', function () { applyFilter(scope); });
|
|
124
124
|
});
|
|
125
125
|
|
|
126
|
-
// ----
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
var opened = Number(store('bench.actions.open')) || 0;
|
|
131
|
-
var fold = function (on) {
|
|
132
|
-
acts.classList.toggle('collapsed', on);
|
|
133
|
-
$('actions-hide').textContent = on ? 'show' : 'Hide';
|
|
134
|
-
$('actions-hide').setAttribute('aria-expanded', String(!on));
|
|
135
|
-
};
|
|
136
|
-
fold(!(Date.now() - opened < 12 * 3600 * 1000));
|
|
137
|
-
$('actions-hide').addEventListener('click', function () {
|
|
138
|
-
var on = !acts.classList.contains('collapsed');
|
|
139
|
-
store('bench.actions.open', on ? '' : String(Date.now()));
|
|
140
|
-
fold(on);
|
|
141
|
-
});
|
|
142
|
-
}
|
|
126
|
+
// ---- a link to another page (At a glance's line to Action needed) ----
|
|
127
|
+
document.querySelectorAll('[data-go]').forEach(function (b) {
|
|
128
|
+
b.addEventListener('click', function () { show(b.dataset.go); scrollTo(0, 0); });
|
|
129
|
+
});
|
|
143
130
|
|
|
144
131
|
// ---- retired rows: hidden unless asked for ---------------------------
|
|
145
132
|
var showRetired = store('bench.retired') === '1';
|
|
@@ -406,8 +393,7 @@
|
|
|
406
393
|
document.querySelectorAll('[data-act]').forEach(function (b) {
|
|
407
394
|
b.addEventListener('click', function () {
|
|
408
395
|
var act = b.dataset.act, aliases = (b.dataset.aliases || b.dataset.alias || '').split(' ').filter(Boolean);
|
|
409
|
-
var status = b.closest('
|
|
410
|
-
if (act === 'open') { openModel(aliases[0]); return; }
|
|
396
|
+
var status = b.closest('[data-row]').querySelector('.why'); // a model's line, or its group's head
|
|
411
397
|
if (act === 'dismiss') { // a notice: gone once seen
|
|
412
398
|
b.disabled = true;
|
|
413
399
|
post('/api/alerts/dismiss', {key: b.dataset.key}).then(function (res) {
|
|
@@ -416,12 +402,22 @@
|
|
|
416
402
|
}).catch(function (err) { status.textContent = String(err); b.disabled = false; });
|
|
417
403
|
return;
|
|
418
404
|
}
|
|
419
|
-
if (act === 'retry') { // no confirm: one call, and only an answer changes anything
|
|
405
|
+
if (act === 'retry') { // no confirm: one call each, and only an answer changes anything
|
|
406
|
+
var label = b.textContent, said = [], failed = [];
|
|
420
407
|
b.disabled = true; b.textContent = 'Asking…';
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
408
|
+
aliases.reduce(function (chain, alias) {
|
|
409
|
+
return chain.then(function () {
|
|
410
|
+
return post('/api/retry', {alias: alias}).then(function (res) {
|
|
411
|
+
(res.ok ? said : failed).push(res.ok ? res.changed.join(' · ') : res.errors.join('; '));
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
}, Promise.resolve()).then(function () {
|
|
415
|
+
// A group's Retry all says how many answered; each failure is on hover, not ten lines in its heading.
|
|
416
|
+
var told = aliases.length > 1 ? said.length + ' of ' + aliases.length + ' answered' : said.concat(failed).join(' · ');
|
|
417
|
+
status.title = failed.join('\n');
|
|
418
|
+
if (!said.length) { status.textContent = told; status.classList.add('err'); b.disabled = false; b.textContent = label; return; }
|
|
419
|
+
status.textContent = told + '. Reloading…'; setTimeout(function () { location.reload(); }, 900);
|
|
420
|
+
}).catch(function (err) { status.textContent = String(err); b.disabled = false; b.textContent = label; });
|
|
425
421
|
return;
|
|
426
422
|
}
|
|
427
423
|
var verb = act === 'restore' ? 'Restore' : 'Retire';
|