@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 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
- "Action needed", above the pages, lists what will not fix itself, such as a
39
- model about to be retired, naming each model and where it comes from, with
40
- Retry, Open and Retire now beside it. It starts folded to its heading and
41
- count; show opens it in that browser for 12 hours. An alert whose models have
42
- all been retired goes at once. A model that is out of quota is not counting
43
- down: days the bench held its calls back until a stated reset, or an account's
44
- shared free quota was spent, pause its clock, and while it is still out of
45
- quota no countdown shows. One refused again after the short wait it named is
46
- failing, and keeps counting.
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.2
1
+ 0.5.3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wendellresearch-ai/benchwarmers",
3
- "version": "0.5.2",
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",
@@ -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), or the account's shared quota is spent (account_quota)."""
86
- return HELD in detail or account_quota(detail)
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.problems()
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 problems() -> str:
2007
- """What needs a person, each with the button that resolves it.
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 from the last week ARE listed, with Restore, because
2012
- a decision taken without a person should be easy for one to undo."""
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
- for key, a in sorted(alerts.items(), key=lambda kv: kv[1].get("first_seen", "")):
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
- try:
2031
- age = (today - datetime.date.fromisoformat(a["first_seen"])).days
2032
- except (KeyError, ValueError):
2033
- age = 0
2034
- # Which model and where it comes from, first: the message alone left that to be looked up.
2035
- served_as = next((s for s, al in names.items() if al == alias), served) if alias else ""
2036
- where = (f"{alias} · {served_as or '?'} via {tag_for(alias) or a.get('endpoint') or '?'}"
2037
- if alias else "")
2038
- why = " · ".join(filter(None, [where, a.get("action", ""),
2039
- "open today" if age == 0 else f"open {age} days"]))
2040
- acts = []
2041
- if a.get("aliases"):
2042
- acts.append(f"<button class='btn small danger' data-act='retire'"
2043
- f" data-aliases='{esc(' '.join(a['aliases']))}'"
2044
- f" data-why='{esc(a.get('message', ''))}'>Retire all {len(a['aliases'])}</button>")
2045
- elif alias in names.values():
2046
- if a.get("kind") == "retiring":
2047
- acts.append(f"<button class='btn small' data-act='retry' data-alias='{esc(alias)}'"
2048
- " title='One call now; an answer clears this alert'>Retry</button>")
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
- if not items and not auto:
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
- # Each row carries its alert key: Hide (bench.js) folds the section to its
2069
- # heading until a key it has not seen arrives, or 12 hours pass. Per browser.
2070
- def render_rows(rows):
2071
- return "".join(
2072
- f"<div class='problem' data-key='{esc(key)}'><div><div class='what'>"
2073
- f"{chip(kind, css) if kind else ''}{esc(what)}</div>"
2074
- f"<div class='why'>{esc(why)}</div></div><div class='acts'>{acts}</div></div>"
2075
- for key, kind, css, what, why, acts in rows)
2076
- # Folded: a week of automatic retirements is a record to check, not a
2077
- # list of things to do, and it must not push the roster off the screen.
2078
- folded = (f"<details><summary>Retired automatically this week · {len(auto)}"
2079
- " <span class='muted'>— each can be restored</span></summary>"
2080
- f"{render_rows(auto)}</details>") if auto else ""
2081
- # Folded by default, the count in its heading; bench.js keeps it open for
2082
- # 12 hours once shown.
2083
- return (f"<section class='problems collapsed' id='actions' aria-labelledby='actions-head'>"
2084
- f"<div class='head'><h2 id='actions-head'>Action needed · {len(items)}</h2>"
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()}{problems()}<div class='layout'>{tabs()}</div></main>{editor()}"
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(f"<button role='tab' data-tab='{key}' aria-selected='false'>{esc(label)}</button>"
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
- /* problems: what needs a person, each with the action that resolves it */
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
- .problems .sep { display: none; color: var(--muted); }
124
- /* Hidden (bench.js): one line, "Action needed · 3 · show", until something new */
125
- .problems.collapsed { padding: .5rem 1.25rem; }
126
- .problems.collapsed > :not(.head) { display: none; }
127
- .problems.collapsed .head h2 { margin: 0; font-size: 1rem; }
128
- .problems.collapsed .sep { display: inline; }
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
- // ---- Action needed: folded to its heading by default; show keeps it open
127
- // in this browser for 12 hours, Hide folds it again ----
128
- var acts = $('actions');
129
- if (acts) {
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('.problem').querySelector('.why');
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
- post('/api/retry', {alias: aliases[0]}).then(function (res) {
422
- if (!res.ok) { status.textContent = res.errors.join('; '); status.classList.add('err'); b.disabled = false; b.textContent = 'Retry'; return; }
423
- status.textContent = res.changed.join(' · ') + '. Reloading…'; setTimeout(function () { location.reload(); }, 900);
424
- }).catch(function (err) { status.textContent = String(err); b.disabled = false; b.textContent = 'Retry'; });
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';